Всё, что мы строили в этой серии — от 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. Если агент сообщает только агрегированный статус «успех/неудача» без разбивки по хостам, команда теряет возможность быстро понять, какой именно сервер требует внимания после частично неудачного запуска.

Что дальше

Тема агентов для админки серверов раскрыта не одной статьёй — вот что почитать следом: