Ansible для 150 серверов: порядок вместо хаоса в ИнфраСервис

Ситуация клиента

В марте 2026 года к нам обратилась MSP-компания «ИнфраСервис», обслуживающая IT-инфраструктуру 35 клиентов. Под их управлением находилось 150 серверов: 90 на CentOS/AlmaLinux, 45 на Ubuntu, 15 на Debian. На серверах крутились веб-приложения, базы данных, почтовые серверы, мониторинг, VPN-шлюзы.

Проблема, с которой обратился руководитель отдела эксплуатации: «Ansible у нас есть, но плейбуки — это комок макарон». Вот что мы обнаружили при аудите:

  • 342 YAML-файла в одной плоской директории без структуры. Некоторые назывались fix_nginx_2.yml, fix_nginx_2_final.yml, fix_nginx_2_final_REAL.yml.
  • Переменные везде: в inventory, в group_vars, в playbooks, в ролях, в extra-vars при запуске. Никто не знал, какое значение перекроет какое.
  • Ноль тестирования: плейбуки тестировались запуском на продакшене. Дважды за последний месяц это приводило к даунтайму.
  • Секреты в открытом виде: пароли к базам данных, API-ключи, SSH-ключи — всё лежало в Git в plain text.
  • Нет единой точки запуска: каждый инженер запускал плейбуки со своего ноутбука с разными версиями Ansible.

Задача: за 4 недели привести всё в порядок — выстроить структуру, внедрить роли, шифрование, тестирование и единую точку запуска через AWX.

Inventory: правильная организация хостов

Первое, что мы сделали — выстроили inventory. Вместо одного файла hosts на 400 строк мы создали директорную структуру:

inventory/
├── production/
│   ├── hosts.yml
│   ├── group_vars/
│   │   ├── all/
│   │   │   ├── vars.yml
│   │   │   └── vault.yml
│   │   ├── webservers.yml
│   │   ├── databases.yml
│   │   └── monitoring.yml
│   └── host_vars/
│       ├── db-master-01.yml
│       └── mail-gw-01.yml
├── staging/
│   ├── hosts.yml
│   └── group_vars/
│       └── all/
│           └── vars.yml
└── development/
    └── hosts.yml

Файл hosts.yml для production выглядел так:

all:
  children:
    webservers:
      hosts:
        web-[01:12].client-alpha.infra.local:
        web-[01:05].client-beta.infra.local:
    databases:
      children:
        postgres_primary:
          hosts:
            db-master-01.infra.local:
            db-master-02.infra.local:
        postgres_replica:
          hosts:
            db-replica-[01:04].infra.local:
    monitoring:
      hosts:
        mon-01.infra.local:
        mon-02.infra.local:
    mailservers:
      hosts:
        mail-gw-[01:03].infra.local:
    vpn_gateways:
      hosts:
        vpn-[01:02].infra.local:

Ключевой принцип: хост может входить в несколько групп, но переменные не должны конфликтовать. Для этого мы использовали строгую иерархию приоритетов Ansible: defaults < group_vars/all < group_vars/group < host_vars < extra-vars.

Для мультиклиентской среды мы добавили второе измерение группировки — по клиентам:

    client_alpha:
      children:
        alpha_web:
          hosts:
            web-[01:12].client-alpha.infra.local:
        alpha_db:
          hosts:
            db-master-01.infra.local:

Это позволяло запускать плейбуки как по типу сервера (-l webservers), так и по клиенту (-l client_alpha).

Роли: структура и принципы разработки

Из 342 файлов мы выделили 18 переиспользуемых ролей. Каждая роль отвечала за одну задачу. Структура роли следовала стандарту Ansible Galaxy:

roles/
├── common/           # Базовая настройка: NTP, DNS, SSH, пакеты
├── hardening/        # CIS-бенчмарки, fail2ban, auditd
├── nginx/            # Установка и конфигурация Nginx
├── postgres/         # PostgreSQL: установка, репликация, бэкапы
├── redis/            # Redis с Sentinel
├── monitoring_agent/ # Node exporter + Promtail
├── certbot/          # Let's Encrypt сертификаты
├── docker_host/      # Docker CE + docker-compose
├── wireguard/        # WireGuard VPN
└── backup_client/    # Restic + расписание бэкапов

Внутри каждой роли — строгая структура:

roles/nginx/
├── defaults/
│   └── main.yml       # Значения по умолчанию — можно переопределить
├── vars/
│   └── main.yml       # Константы роли — не переопределять
├── tasks/
│   ├── main.yml       # Точка входа
│   ├── install.yml    # Установка пакетов
│   ├── configure.yml  # Шаблоны конфигов
│   └── service.yml    # Systemd-юнит
├── handlers/
│   └── main.yml       # Перезапуск при изменении конфига
├── templates/
│   ├── nginx.conf.j2
│   └── vhost.conf.j2
├── files/
│   └── dhparam.pem
├── meta/
│   └── main.yml       # Зависимости роли
└── molecule/
    └── default/       # Тесты

Критически важный принцип, который мы донесли до команды клиента: роль — это не функция. Нельзя передавать в роль «параметры» и ожидать изоляции. Все переменные Ansible глобальны в пределах play. Если две роли используют переменную port, они перезапишут друг друга.

Поэтому мы префиксировали все переменные ролей:

# roles/nginx/defaults/main.yml
nginx_worker_processes: auto
nginx_worker_connections: 4096
nginx_client_max_body_size: 64m
nginx_ssl_protocols: "TLSv1.2 TLSv1.3"
nginx_access_log: /var/log/nginx/access.log
nginx_error_log: /var/log/nginx/error.log

Jinja2-шаблоны и handlers

Шаблоны — сердце конфигурационного управления. Мы написали Jinja2-шаблоны для каждого конфигурационного файла. Пример шаблона для Nginx virtualhost:

# roles/nginx/templates/vhost.conf.j2
{% for site in nginx_sites %}
server {
    listen 443 ssl http2;
    server_name {{ site.domain }};

    ssl_certificate /etc/letsencrypt/live/{{ site.domain }}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{{ site.domain }}/privkey.pem;
    ssl_protocols {{ nginx_ssl_protocols }};

    root {{ site.root | default('/var/www/' + site.domain) }};
    index index.html index.php;

{% if site.php | default(false) %}
    location ~ \.php$ {
        fastcgi_pass unix:/run/php/php{{ site.php_version | default('8.2') }}-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }
{% endif %}

{% if site.proxy_pass is defined %}
    location / {
        proxy_pass {{ site.proxy_pass }};
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
{% endif %}

    access_log {{ nginx_access_log }} combined;
    error_log {{ nginx_error_log }} warn;
}
{% endfor %}

Для handlers мы выработали жёсткое правило: handlers используются только для перезапуска сервисов при изменении конфига. Всё остальное — обычные tasks.

# roles/nginx/handlers/main.yml
---
- name: Reload nginx
  ansible.builtin.systemd:
    name: nginx
    state: reloaded
  listen: "nginx config changed"

- name: Restart nginx
  ansible.builtin.systemd:
    name: nginx
    state: restarted
  listen: "nginx needs restart"

Почему это важно: handlers выполняются только при статусе changed. При повторном запуске плейбука, если конфиг не менялся, handler не вызовется. Это идемпотентно — то есть безопасно запускать плейбук сколько угодно раз.

Но есть подводный камень: порядок выполнения. Handlers срабатывают после каждой секции (pre_tasks → handlers → roles → handlers → tasks → handlers → post_tasks → handlers). Если вы перенесёте task из post_tasks в pre_tasks — handler может выполниться раньше, чем ожидалось, и сломать порядок зависимостей. Мы столкнулись с этим, когда задача установки SSL-сертификата переехала, а handler перезапуска Nginx вызывался до генерации dhparam.

Ansible Vault: шифрование секретов

При аудите мы нашли 47 паролей и 12 API-ключей в plain text в Git-репозитории. Первым делом мы провели ротацию всех скомпрометированных секретов, а затем внедрили Ansible Vault.

Архитектура хранения секретов:

# inventory/production/group_vars/all/vars.yml — публичные переменные
app_db_name: production_db
app_db_user: app_user
app_db_host: db-master-01.infra.local

# inventory/production/group_vars/all/vault.yml — зашифрованные
app_db_password: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  6238396639363931353662383...
api_key_monitoring: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  3364343733636533613266643...

Мы настроили автоматическое шифрование через .vault-password-file и Git pre-commit hook, который проверял, что ни один файл vault.yml не попадает в коммит в расшифрованном виде:

#!/bin/bash
# .git/hooks/pre-commit
for file in $(git diff --cached --name-only | grep vault); do
  if ! head -1 "$file" | grep -q '\$ANSIBLE_VAULT'; then
    echo "ERROR: $file is not encrypted! Run: ansible-vault encrypt $file"
    exit 1
  fi
done

Для разных клиентов мы использовали разные пароли шифрования через vault-id:

# Шифрование для конкретного клиента
ansible-vault encrypt --vault-id client_alpha@prompt vault.yml

# Запуск с несколькими vault-id
ansible-playbook site.yml \
  --vault-id client_alpha@~/vault_passwords/alpha.txt \
  --vault-id client_beta@~/vault_passwords/beta.txt

Тестирование с Molecule

Самое болезненное открытие для команды клиента: до нас плейбуки ни разу не тестировались в изолированной среде. Мы внедрили Molecule — фреймворк для тестирования ролей Ansible.

Конфигурация Molecule для роли nginx:

# roles/nginx/molecule/default/molecule.yml
---
dependency:
  name: galaxy
driver:
  name: docker
platforms:
  - name: ubuntu-22
    image: geerlingguy/docker-ubuntu2204-ansible
    pre_build_image: true
    privileged: true
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    command: /lib/systemd/systemd
  - name: alma-9
    image: geerlingguy/docker-rockylinux9-ansible
    pre_build_image: true
    privileged: true
    volumes:
      - /sys/fs/cgroup:/sys/fs/cgroup:rw
    command: /lib/systemd/systemd
provisioner:
  name: ansible
  inventory:
    group_vars:
      all:
        nginx_sites:
          - domain: test.example.com
            root: /var/www/test
verifier:
  name: testinfra

Тесты на Python с testinfra проверяли результат применения роли:

# roles/nginx/molecule/default/tests/test_default.py
import pytest

def test_nginx_installed(host):
    pkg = host.package("nginx")
    assert pkg.is_installed

def test_nginx_running(host):
    svc = host.service("nginx")
    assert svc.is_running
    assert svc.is_enabled

def test_nginx_listening(host):
    sock = host.socket("tcp://0.0.0.0:443")
    assert sock.is_listening

def test_nginx_config_valid(host):
    cmd = host.run("nginx -t")
    assert cmd.rc == 0

def test_vhost_exists(host):
    f = host.file("/etc/nginx/conf.d/test.example.com.conf")
    assert f.exists
    assert f.contains("server_name test.example.com")

Запуск тестов встроили в CI/CD: каждый merge request с изменениями в ролях автоматически прогонял molecule test на двух платформах. Время прогона — 4-6 минут. За первый месяц тесты поймали 7 ошибок до того, как они попали в продакшен.

AWX и результаты внедрения

Последний шаг — единая точка запуска. Мы развернули AWX (open-source версия Ansible Tower) на выделенном сервере:

# Установка AWX через Kubernetes operator
helm repo add awx-operator https://ansible.github.io/awx-operator/
helm install awx-operator awx-operator/awx-operator -n awx --create-namespace

# Создание инстанса AWX
kubectl apply -f - <

AWX дал команде несколько критических преимуществ:

  • RBAC — младшие инженеры могут запускать только безопасные плейбуки (мониторинг, диагностика), а деплой и изменения конфигурации доступны только старшим.
  • Аудит — каждый запуск записывается: кто, когда, какой плейбук, на каких хостах, с каким результатом.
  • Расписание — ежедневная проверка compliance (hardening-роль в check mode), еженедельное обновление пакетов безопасности.
  • Единая версия — AWX тянет плейбуки из Git, все используют одну и ту же версию.

Результаты за первый месяц работы по новой схеме:

МетрикаДоПосле
Время настройки нового сервера4-6 часов25 минут
Инциденты из-за конфигурации8 в месяц1 в месяц
Время на рутинные задачи60% рабочего дня15%
Секреты в plain text47 паролей0
Документированные роли018
Покрытие тестами ролей0%100%

Команда «ИнфраСервис» продолжает развивать инфраструктуру как код. Мы в itfresh.ru провели два тренинга для инженеров и передали полную документацию по всем ролям и процессам. Если вашему MSP нужна помощь с организацией Ansible — обращайтесь.

Часто задаваемые вопросы

Ansible работает без агентов — подключается по SSH и выполняет задачи. Puppet и Chef требуют установки агента на каждый сервер. Ansible проще в освоении (YAML вместо Ruby/DSL), но при 500+ серверах агентная модель может быть эффективнее из-за pull-модели.
Используйте отдельные inventory-директории для каждого окружения (production, staging) и группировку хостов по клиентам внутри inventory. Секреты шифруйте через Ansible Vault с отдельными vault-id для каждого клиента. AWX обеспечит RBAC и аудит запусков.
Для команды из 1-2 человек командная строка достаточна. При 3+ инженерах AWX окупается за счёт единой точки запуска, RBAC, аудита и расписания. Бесплатный AWX покрывает 90% задач, платный Ansible Tower добавляет поддержку и кластеризацию.
Используйте Molecule с Docker-драйвером — он создаёт изолированные контейнеры, применяет роль и проверяет результат через testinfra. Интегрируйте тесты в CI/CD: каждый merge request с изменениями в ролях должен проходить molecule test.
Ansible Vault шифрует файлы и отдельные переменные с помощью AES-256. Храните зашифрованные файлы в Git, пароль шифрования — вне репозитория. Используйте Git pre-commit hook для проверки, что vault-файлы не попадают в коммит в открытом виде.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#ansible#playbook#devops#автоматизация#inventory#ansible roles#ansible vault#molecule
Комментарии 0

Оставить комментарий

загрузка...