Мультисерверный агент: связка Ansible и LLM
Всё, что мы строили в этой серии — от SSH-доступа до allowlist команд, — прекрасно работает, пока речь идёт о паре серверов, к которым агент подключается напрямую. Но как только парк вырастает до десятка и больше хостов, прямые SSH-вызовы начинают трещать по швам: нужно параллельное выполнение, единообразие команд между разными версиями окружений, откат при частичном сбое. Здесь на помощь приходит Ansible — инструмент, который десятилетиями решает именно эту задачу для людей, и который прекрасно сочетается с LLM-агентом, если не пытаться заменить его, а встроить агента поверх.
В этой статье разберём, как агент на srv-ai01 использует Ansible вместо прямых SSH-вызовов для операций, затрагивающих несколько серверов сразу — например, обновление конфигурации на всех веб-серверах или синхронный перезапуск сервиса на группе хостов в сети 192.168.10.0/24, где кроме srv-web01 и srv-db01 в инвентаре появляются ещё несколько машин той же роли.
Шаг 1. Инвентарь Ansible как единственный источник истины о серверах
Вместо захардкоженного словаря ALLOWED_HOSTS, который мы использовали в статьях про одиночный SSH-доступ, для мультисерверного сценария источником истины становится inventory-файл Ansible — агент читает список серверов и их ролей из него, а не дублирует эту информацию в своём коде.
# /etc/ansible/hosts.yml
all:
children:
web:
hosts:
srv-web01: {ansible_host: 192.168.10.11}
srv-web02: {ansible_host: 192.168.10.13}
db:
hosts:
srv-db01: {ansible_host: 192.168.10.12}
network:
hosts:
rtr-edge01: {ansible_host: 192.168.10.1}
Такая структура сразу даёт агенту группировку по ролям — модель может получить запрос «перезапусти nginx на всех веб-серверах» и оперировать группой web, а не перечислять хосты вручную. Это снижает вероятность того, что при добавлении нового сервера в инфраструктуру про него забудут упомянуть агенту отдельно — достаточно добавить хост в inventory, и он автоматически попадает в область действия существующих плейбуков и групповых команд.
Шаг 2. Идемпотентные плейбуки вместо разовых shell-команд
Ключевое отличие подхода с Ansible от прямых SSH-вызовов — вместо императивной команды «выполни systemctl restart» агент запускает декларативный плейбук, который описывает желаемое состояние, а не последовательность действий. Это даёт идемпотентность: повторный запуск того же плейбука безопасен и не создаёт побочных эффектов, если состояние уже достигнуто.
# playbooks/restart-nginx.yml
- hosts: "{{ target_group }}"
become: true
tasks:
- name: Restart nginx
ansible.builtin.systemd:
name: nginx
state: restarted
- name: Verify nginx is active
ansible.builtin.systemd:
name: nginx
state: started
register: result
- name: Fail if not running
ansible.builtin.fail:
msg: "nginx did not start successfully"
when: result.status.ActiveState != "active"
Обрати внимание на встроенную проверку состояния после перезапуска — плейбук не просто выполняет действие, а сразу подтверждает, что результат соответствует ожиданию, и явно падает с понятной ошибкой, если это не так. Такую же логику verify мы применяли в статье про агента-деплой, и здесь она даже удобнее реализуется штатными средствами Ansible.
Шаг 3. Запуск плейбука из кода агента через ansible-runner
Вместо вызова Ansible через subprocess с парсингом текстового вывода, используй библиотеку ansible-runner, которая даёт структурированный результат и упрощает обработку ошибок на уровне отдельных хостов.
import ansible_runner
def run_playbook(playbook, target_group, extra_vars=None):
result = ansible_runner.run(
private_data_dir="/opt/ai-agent/ansible",
playbook=playbook,
inventory="/etc/ansible/hosts.yml",
limit=target_group,
extravars=extra_vars or {},
quiet=True,
)
per_host = {}
for event in result.events:
if event.get("event") == "runner_on_ok":
host = event["event_data"]["host"]
per_host[host] = "ok"
elif event.get("event") == "runner_on_failed":
host = event["event_data"]["host"]
per_host[host] = "failed"
return {"status": result.status, "per_host": per_host}
Результат в виде per_host критически важен для мультисерверных операций: если из пяти веб-серверов группы четыре перезапустились успешно, а один упал с ошибкой, агент должен явно сообщить об этом частичном сбое, а не отчитаться общим «готово» или общим «не готово», скрывающим детали за агрегированным статусом.
Шаг 4. Ограничение доступных плейбуков вместо произвольных команд
Тот же принцип allowlist, что мы применяли к shell-командам, здесь работает даже проще: агент не может запускать произвольный плейбук, только явно зарегистрированные в системе, каждый со своей политикой риска и требованием подтверждения для операций уровня dangerous.
PLAYBOOK_REGISTRY = {
"restart-nginx": {"path": "playbooks/restart-nginx.yml", "risk": "reversible"},
"reload-config": {"path": "playbooks/reload-config.yml", "risk": "reversible"},
"rotate-logs": {"path": "playbooks/rotate-logs.yml", "risk": "safe"},
"db-schema-migrate": {"path": "playbooks/db-migrate.yml", "risk": "dangerous"},
}
def execute_playbook_request(name, target_group, requester):
entry = PLAYBOOK_REGISTRY.get(name)
if entry is None:
return {"ok": False, "error": f"playbook {name} is not registered"}
if RISK_LEVELS[entry["risk"]]["requires_confirmation"]:
if not request_confirmation_sync(name, target_group, requester):
return {"ok": False, "error": "not confirmed"}
return run_playbook(entry["path"], target_group)
Модель со стороны LLM никогда не видит и не формирует произвольный YAML плейбука — она выбирает имя из зарегистрированного списка и указывает целевую группу, а вся логика выполнения остаётся заранее написанной и проверенной человеком, точно так же, как в статье про allowlist команд для одиночных серверов.
Шаг 5. Постепенный rollout вместо одновременного запуска на всей группе
Одновременный перезапуск сервиса сразу на всех серверах группы рискован — если конфигурация содержит ошибку, ты кладёшь весь сервис целиком вместо одного хоста. Ansible поддерживает пакетное выполнение через параметр serial, который стоит использовать для любой операции, способной повлиять на доступность сервиса.
# playbooks/rolling-restart.yml
- hosts: "{{ target_group }}"
serial: "25%"
become: true
tasks:
- name: Restart service
ansible.builtin.systemd:
name: "{{ service_name }}"
state: restarted
- name: Health check after restart
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}/healthz"
status_code: 200
retries: 5
delay: 5
serial: "25%" означает, что Ansible обрабатывает серверы группы партиями по четверти от общего числа, и если health check после рестарта не проходит на текущей партии, выполнение останавливается перед переходом к следующей — весь сервис не остаётся без доступности одновременно, а проблема на одном сервере не долетает до всей группы.
Шаг 6. Динамический inventory вместо статичного YAML-файла
Статичный hosts.yml из шага 1 отлично подходит для лаборатории из десятка машин, но в реальной инфраструктуре серверы появляются и выводятся из эксплуатации чаще, чем кто-то вспоминает вручную обновить inventory-файл. Рассинхронизация между реальным парком и файлом, который читает агент, опасна ровно в обе стороны: агент может не увидеть новый сервер и пропустить его при групповой операции, а может, наоборот, продолжать пытаться достучаться до давно списанной машины. Ansible поддерживает динамический inventory — скрипт или плагин, который на каждый запуск отдаёт актуальный список хостов из внешнего источника, например из CMDB компании или из API облачного провайдера.
# /etc/ansible/inventory_dynamic.py
#!/usr/bin/env python3
import json
import requests
def fetch_inventory() -> dict:
resp = requests.get("http://cmdb.itpages.lab.local/api/hosts", timeout=5)
resp.raise_for_status()
hosts = resp.json()
inventory = {"_meta": {"hostvars": {}}}
for host in hosts:
group = host["role"]
inventory.setdefault(group, {"hosts": []})
inventory[group]["hosts"].append(host["name"])
inventory["_meta"]["hostvars"][host["name"]] = {
"ansible_host": host["ip"],
}
return inventory
if __name__ == "__main__":
print(json.dumps(fetch_inventory()))
Вызов ansible_runner.run(...) из шага 3 меняется минимально — вместо пути к статичному файлу указывается путь к исполняемому скрипту с флагом --list, который Ansible вызывает автоматически. Для агента это выгодно вдвойне: во-первых, группа web в запросе «перезапусти nginx на всех веб-серверах» всегда соответствует реальному состоянию парка, а во-вторых, само наличие единого источника правды в CMDB снимает с агента ответственность за то, чтобы кто-то не забыл вручную дописать новый srv-web03 в конфиг — источник один, и он общий для людей и для агента.
Шаг 7. Асинхронный запуск и отчёт о прогрессе для длительных rollout
Rolling-обновление с serial: "25%" на группе из полутора десятков серверов с health check и паузами между партиями может занимать несколько минут — и если агент общается с администратором через чат или Telegram, держать HTTP-запрос открытым всё это время не вариант: он либо упрётся в таймаут, либо оставит пользователя смотреть на «печатает…» без обратной связи о том, что происходит на самом деле. ansible_runner поддерживает асинхронный запуск через run_async, который сразу возвращает управление, а статус можно опрашивать отдельно.
import ansible_runner
def start_rollout(playbook, target_group, extra_vars=None) -> str:
thread, runner = ansible_runner.run_async(
private_data_dir="/opt/ai-agent/ansible",
playbook=playbook,
inventory="/etc/ansible/inventory_dynamic.py",
limit=target_group,
extravars=extra_vars or {},
quiet=True,
)
RUNNING_JOBS[runner.config.ident] = runner
return runner.config.ident
def get_rollout_status(job_id: str) -> dict:
runner = RUNNING_JOBS.get(job_id)
if runner is None:
return {"error": "unknown job_id"}
per_host = {}
for event in runner.events:
if event.get("event") in ("runner_on_ok", "runner_on_failed"):
host = event["event_data"]["host"]
per_host[host] = "ok" if event["event"] == "runner_on_ok" else "failed"
return {"status": runner.status, "per_host": per_host}
Дальше это оборачивается в отдельный инструмент для модели — например, start_rollout запускается один раз и возвращает job_id, а get_rollout_status модель вызывает по запросу пользователя вида «как там дела с обновлением» или по собственной инициативе каждые несколько итераций цикла, если задача ещё не завершена. Такой же принцип уже применялся в статье про агента-деплой для длительных CI/CD-пайплайнов, и здесь он переносится без изменений: любая операция, которая может идти дольше нескольких секунд, не должна блокировать основной цикл агента, а должна опрашиваться отдельно и уметь честно ответить «ещё выполняется, готово 8 из 15 серверов» вместо тишины.
Частые ошибки и как их избежать
- Одновременное выполнение на всей группе серверов без rolling-стратегии. Без
serialи промежуточных health-check ошибка в изменении затрагивает сразу весь сервис, а не постепенно выявляется на части хостов. - Модель формирует произвольный Ansible YAML. Так же как с shell-командами, доверять LLM генерацию исполняемого кода для продакшена — риск: используй заранее написанные и проверенные плейбуки, выбираемые моделью по имени, а не создаваемые на лету.
- Игнорирование частичных сбоев в результатах per-host. Если агент сообщает только агрегированный статус «успех/неудача» без разбивки по хостам, команда теряет возможность быстро понять, какой именно сервер требует внимания после частично неудачного запуска.
Что дальше
Тема агентов для админки серверов раскрыта не одной статьёй — вот что почитать следом: