MAATRIX / Блог / Ansible прогнали не на той группе хостов: 20 машин получили чужой конфиг

Ansible прогнали не на той группе хостов: 20 машин получили чужой конфиг

MAATRIX

Пятничным вечером инженер хотел прогнать небольшое изменение на пяти стейджинговых хостах — включить подробное логирование перед демонстрацией новой фичи. ansible-playbook отчитался: changed=25. Через пятнадцать минут в проде начали расти 502-е, диски на боевых нодах стали пухнуть от debug-логов, а часть трафика улетела на давно списанный тестовый бэкенд. Разберём по шагам, как безобидный на вид запуск playbook задел 20 продакшн-машин, почему это не бросилось в глаза сразу и какая механика Ansible на самом деле в этом виновата.

Что сломалось

Задача была рутинная: включить на стейджинговой группе webservers (пять хостов) новый шаблон nginx-конфига с расширенным access-логом и заглушкой на тестовый API — чтобы продемонстрировать фичу заказчику в понедельник. Инженер запустил деплой через обёрточный скрипт:

./deploy.sh staging web --tags config

Скрипт под капотом дергал:

ansible-playbook -i inventories/ playbooks/deploy_web.yml \
  --limit webservers --tags config

PLAY RECAP показал changed=25 вместо ожидаемых пяти. Инженер это увидел, но решил, что просто накопились ещё какие-то хосты по тегам, и не стал разбираться — деплой прошёл «зелёным», сервис отвечал. Настоящая проблема проявилась не сразу: часть боевого трафика начала идти через nginx-шаблон, рассчитанный на стейджинг — с заглушкой вместо реального апстрима и без нормального кеширования статики. В течение получаса это превратилось в заметный рост 502/504 на продакшн-домене и жалобы пользователей в поддержку.

Итог по факту: из 25 хостов, которые «задело» плейбуком, 20 были боевыми продакшн-серверами, которые вообще не должны были попадать под этот запуск. Они получили конфиг, предназначенный для тестового окружения.

Что показали логи и метрики

Первым делом посмотрели на графики — и разброс по времени точно совпал с временем запуска Ansible:

  • Дисковый I/O и место на диске на 20 прод-нодах начали расти практически вертикально сразу после PLAY RECAP — новый формат access-лога писал в разы больше данных на каждый запрос.
  • Частота 502/504 в nginx error.log пошла вверх с той же секунды, что и таймстамп в ansible.log (плейбук пишет лог callback-плагином log_plays — этого оказалось достаточно, чтобы сопоставить события по секундам).
  • Diff конфигов: сравнили /etc/nginx/conf.d/app.conf на затронутых прод-хостах с версией в git через ansible -i inventories/prod ansible_all -m fetch — на месте боевого конфига оказался шаблон с директивами add_header X-Debug-Mode "on" и upstream, указывающим на IP тестового мок-сервиса, который давно не поднимался под нагрузкой.
  • Ошибки в системе парсинга логов: ELK/Filebeat начал ронять часть событий, потому что новый формат access-лога не совпадал с ожидаемым grok-паттерном — это добавило шума и первое время маскировало реальную причину.

Совпадение по времени с точностью до секунды сразу сузило круг подозреваемых до одного конкретного запуска Ansible, а не до «чего-то в целом пошло не так».

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Какие гипотезы отбросили

  • «Кто-то выкатил плохой релиз через CI/CD» — проверили историю пайплайна деплоя приложения: версия бэкенда не менялась, последний релиз был на два дня раньше и не коррелировал по времени.
  • «Проблема на стороне БД или бэкенда» — метрики базы и сервисов приложения были в норме, ошибки рождались именно на уровне nginx, конкретно в конфиге апстрима.
  • «Сетевой сбой у провайдера» — traceroute и графики сетевого RTT между узлами ничего не показали, задетые ноды были доступны и отвечали, просто отвечали неправильно.
  • «Кто-то вручную поправил конфиг на проде» — подняли auth.log и last на подозрительных хостах: интерактивных SSH-сессий в нужное время не было, а время модификации файлов /etc/nginx/conf.d/app.conf (stat показал mtime) совпадало с запуском именно от сервисного CI-пользователя, от имени которого гоняется Ansible.
  • «Баг в самом Ansible или в роли nginx» — прогнали тот же playbook с тем же тегом на чистом тестовом хосте с явным -i inventories/staging/hosts.ini: получили ровно один изменённый хост, как и ожидалось. Это исключило баг в самой роли и переключило подозрение на инвентарь.

Как раскопали настоящую причину

Ключевую подсказку дала простая команда, которую в спешке никто не догадался запустить сразу:

ansible-inventory -i inventories/ --graph webservers

Вывод показал группу webservers, в которой оказались хосты и с префиксом web-stage-, и с префиксом web-prod- — все 25 разом, в одной группе. Для сравнения, тот же запрос с явным путём к одному файлу вёл себя иначе:

ansible-inventory -i inventories/staging/hosts.ini --graph webservers
# webservers -> 5 хостов web-stage-*

ansible-inventory -i inventories/prod/hosts.ini --graph webservers
# webservers -> 20 хостов web-prod-*

То есть по отдельности каждый инвентарь описывал свою группу webservers корректно. Проблема начиналась там, где ansible.cfg указывал не на конкретный файл, а на директорию целиком:

[defaults]
inventory = ./inventories

Три недели назад команда провела рефакторинг инвентаря — вместо одного плоского hosts.ini завели отдельные подпапки inventories/prod/ и inventories/staging/ «для порядка». Формально это выглядело правильно, но никто не учёл, как Ansible обрабатывает инвентарь, если ему передана директория: он вычитывает все файлы внутри неё и объединяет данные, а группы с одинаковым именем из разных источников сливаются в одну, а не остаются раздельными по подпапкам. git log по каталогу инвентаря показал именно тот коммит с сообщением «reorganize inventory into per-env folders», после которого баг стал возможен — но не проявлялся, потому что почти все повседневные запуски явно указывали путь к нужному файлу через -i inventories/staging/hosts.ini. Ловушка сработала только там, где скрипт-обёртка deploy.sh для удобства (чтобы не переписывать его при добавлении новых окружений) указывал inventory директорией целиком.

В чём была настоящая причина

Механика простая и в этом её опасность: у Ansible группа — это просто имя, а не пространство имён, привязанное к файлу. Если два разных источника инвентаря описывают группу с одинаковым названием, Ansible не воспринимает их как «группа A из файла 1» и «группа A из файла 2» — он видит одну группу A и объединяет списки хостов. Схематично то, что происходило:

# inventories/prod/hosts.ini
[webservers]
web-prod-01 ansible_host=10.20.1.11
web-prod-02 ansible_host=10.20.1.12
...
# ещё 18 хостов

# inventories/staging/hosts.ini
[webservers]
web-stage-01 ansible_host=10.20.9.11
...
# ещё 4 хоста

При inventory = ./inventories (директория) итоговая группа webservers — это объединение обоих списков, 25 хостов. Флаг --limit webservers в этом случае ограничивает выполнение до «всех, кто состоит в группе webservers» — а таких, как выяснилось, оказалось не пять, а двадцать пять, потому что имя группы конфликтовало между окружениями. Playbook, задуманный как безопасное точечное изменение на стейджинге, фактически превратился в деплой на весь боевой веб-флот плюс стейджинг.

Отдельно стоит подчеркнуть: это не баг Ansible, а задокументированное поведение слияния инвентарей — просто оно контринтуитивно и легко забывается, когда структуру каталогов меняют «для порядка», не пересматривая, как её читает ansible.cfg и обёрточные скрипты. Похожие грабли разбирались и в статье про частые ошибки при работе с Ansible на сервере — путаница с инвентарём там тоже в списке частых причин «съеденного» деплоя.

Что изменили после инцидента

Первым делом откатили конфиг на 20 затронутых прод-хостах — точечным прогоном с явным инвентарём и --limit по конкретным именам хостов, а не по имени группы:

ansible-playbook -i inventories/prod/hosts.ini playbooks/deploy_web.yml \
  --limit 'web-prod-01,web-prod-02,...,web-prod-20' --tags config

После разбора причины внесли несколько изменений в процесс:

  • Переименовали группы с префиксом окружения. Вместо одинакового webservers в обоих инвентарях теперь prod_webservers и staging_webservers. Общая группа webservers осталась только как явный children-алиас внутри одного файла, если она действительно нужна — но никогда не как результат случайного слияния двух источников.
  • Запретили указывать inventory директорией в скриптах деплоя. deploy.sh теперь принимает окружение как обязательный параметр и явно подставляет путь к одному файлу: -i "inventories/${ENV}/hosts.ini". Если переменная ENV не задана — скрипт падает с ошибкой, а не выбирает «всё подряд».
  • Добавили предварительную проверку количества хостов. Перед реальным прогоном playbook сверяет вывод ansible-inventory --list с ожидаемым числом хостов, зафиксированным в отдельном файле в репозитории (inventories/prod/expected_hosts_count). Если фактическое число не совпадает — запуск останавливается до применения изменений.
  • Сделали --check --diff обязательным шагом перед прод-изменениями. Сухой прогон показывает, какие файлы реально поменяются и на каких хостах, прежде чем что-либо применяется по-настоящему.
  • Завели уведомление о PLAY RECAP в чат команды. Итог каждого прогона (сколько хостов затронуто, на каком инвентаре) теперь падает в отдельный канал автоматически — аномальное число изменённых хостов видно сразу, а не после жалоб пользователей.
  • Развели vault-секреты по окружениям. Раньше один и тот же vault-файл обслуживал оба окружения; теперь у прод и стейджинга разные ключи шифрования — это дополнительный барьер, если инвентарь всё же перепутается снова.

Похожий подход к разделению окружений и защите от «случайного прода» описан в статье про настройку staging-копии сайта — там же разбирается, почему тестовое и боевое окружение стоит держать физически и логически раздельными, вплоть до отдельных серверов.

Чек-лист для тех, кто гоняет Ansible на нескольких окружениях

Если у вас несколько инвентарей (прод, стейджинг, дев) и хотя бы один общий playbook — стоит явно пройтись по этим пунктам, желательно до того, как случится похожий инцидент:

РискКак проверитьЧто сделать
Совпадающие имена групп в разных инвентаряхansible-inventory -i <path> --graph <group> на каждом источнике отдельноДобавить префикс окружения в имена групп
inventory в ansible.cfg указывает на директорию`cat ansible.cfg \grep inventory`Указывать конкретный файл или явный -i в каждом вызове
--limit по имени группы без проверки составаПеред прод-изменением сверять --list-hosts с ожидаемым списком
Один vault-ключ на все окруженияcat group_vars/*/vault.ymlРазделить ключи шифрования по окружениям
Нет сухого прогона перед прод-изменениемСделать --check --diff обязательным шагом в CI
Нет уведомления об итогах прогонаСлать PLAY RECAP в чат команды автоматически

Дополнительно полезно завести привычку: перед любым изменением, которое может задеть больше нескольких хостов, сначала вывести список целей без применения:

ansible-playbook -i inventories/prod/hosts.ini playbooks/deploy_web.yml \
  --limit webservers --list-hosts

Если число в выводе отличается от того, что вы держите в голове — это сигнал остановиться и разобраться, а не жать Enter ещё раз. Ту же логику «сначала диагностика, потом действие» стоит применять и к самим конфигам — в статье про антипаттерн правки конфигов прямо на проде разобрано, почему любое изменение боевого конфига должно проходить через контролируемый процесс, а не «на глаз». А если ваш стек ещё не оформлен как воспроизводимый playbook — с чего начать автоматизацию, разобрано в статье про пример playbook для настройки сервера.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Как быстро проверить, что группа в инвентаре содержит именно те хосты, что нужно, до запуска playbook?

Выполните ansible-inventory -i <путь> --graph <имя_группы> или ansible-playbook ... --limit <группа> --list-hosts — обе команды показывают итоговый список без применения изменений. Делать это стоит каждый раз, когда меняли структуру инвентаря или добавляли новое окружение.

Можно ли всё же называть группы одинаково в разных инвентарях, если это сделано осознанно?

Технически да — это стандартный способ объединять хосты из разных источников в одну логическую группу. Проблема не в самом слиянии, а в том, что оно произошло случайно, без намерения. Если объединение нужно осознанно, лучше сделать это явным children-блоком в одном месте, а не полагаться на совпадение имён между отдельными файлами.

Что делать, если часть прод-серверов уже получила чужой конфиг?

Сначала откатить конфиг точечно — по именам конкретных хостов, а не по группе, чтобы случайно не задеть ещё что-то. Затем сверить состояние всех затронутых машин с git-версией конфигурации (например, через ansible -m fetch или ansible -m file для диффа), и только после этого разбирать первопричину — чинить симптом и искать корень стоит раздельно, чтобы не потерять след.

Помогает ли AWX или Semaphore избежать такой ошибки?

Отчасти — эти инструменты обычно заставляют явно выбирать инвентарь и окружение из выпадающего списка, а не собирать команду вручную, что убирает часть человеческого фактора. Но сама механика слияния групп с одинаковыми именами при этом никуда не девается — если внутри выбранного инвентаря группы всё ещё пересекаются по имени, проблема повторится и через веб-интерфейс.

Стоит ли вообще хранить инвентари разных окружений в одном репозитории?

Хранить в одном репозитории — нормально и удобно для версионирования, проблема не в этом, а в том, как инвентарь читается при запуске. Если каждый вызов Ansible явно указывает конкретный файл инвентаря для нужного окружения (а не директорию с несколькими файлами), риск случайного слияния групп исчезает вне зависимости от структуры репозитория.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →