Практикум: Автоматизация развертывания инфраструктуры и сервисов

Интеграционный курс по объединению Terraform, Ansible и Docker в единый конвейер развертывания. Вы научитесь создавать облачную инфраструктуру в Yandex Cloud и автоматически превращать «голые» виртуальные машины в готовые узлы с контейнеризированными сервисами.

1. Связующее звено: Интеграция Terraform и Ansible через динамический Inventory

Связующее звено: Интеграция Terraform и Ansible через динамический Inventory

Команда terraform apply успешно завершена. В облаке запущены десятки виртуальных машин: балансировщики, узлы базы данных, воркеры для фоновых задач. Провайдер Yandex Cloud динамически выделил им внутренние и публичные IP-адреса. Следующий шаг — настройка операционных систем и установка Docker через Ansible. Возникает фундаментальный разрыв: Terraform знает всё о созданной инфраструктуре, но не умеет настраивать ОС, а Ansible обладает мощными модулями конфигурации, но понятия не имеет, по каким IP-адресам стучаться и какие роли назначены серверам. Ручное копирование адресов в статический файл hosts.ini уничтожает саму идею автоматизации и делает невозможным использование автомасштабируемых групп или прерываемых машин.

Проблема передачи состояния между инструментами IaC (Infrastructure as Code) и инструментами управления конфигурацией — один из главных вызовов при построении CI/CD пайплайнов. Инфраструктура в облаке эфемерна. Виртуальная машина может быть пересоздана в любой момент, получив новый IP-адрес. Если Ansible опирается на жестко заданный текстовый файл, возникает рассинхронизация: конфигуратор пытается подключиться к несуществующим узлам или пропускает новые.

Для решения этой задачи применяется паттерн Dynamic Inventory (динамический инвентарь). Вместо чтения статического файла, Ansible в реальном времени опрашивает внешний источник правды, чтобы получить актуальный список хостов, их адреса и метаданные.

Существует три архитектурных подхода к интеграции Terraform и Ansible.

| Подход | Механика | Преимущества | Недостатки | |---|---|---|---| | Генерация файла (Push) | Terraform через local-exec и функцию templatefile() создает файл hosts.ini после развертывания ВМ. | Не требует настройки Ansible. Максимально просто в реализации. | Файл остается на диске локальной машины. При удалении ВМ файл нужно перегенерировать. Нарушается идемпотентность. | | Облачный API (Pull) | Ansible использует плагин yandex_cloud_compute для прямого опроса API Yandex Cloud. | Ansible независим от Terraform. Всегда видит реальное состояние облака. | Требует передачи сервисных ключей облака в Ansible. Дублирование логики (Ansible должен сам понимать, какие ВМ относятся к проекту). | | Чтение State-файла (State Parsing) | Ansible читает файл terraform.tfstate напрямую через плагин terraform. | Единый источник правды. Не нужны доступы к облаку для Ansible. Точное соответствие тому, что создал Terraform. | Требует доступа к локальному или удаленному backend'у Terraform (например, S3-бакету). |

В современной инженерной практике стандартом де-факто является чтение State-файла. Файл состояния Terraform (.tfstate) — это подробный JSON-документ. В нем зафиксированы не только IP-адреса, но и все атрибуты ресурсов: идентификаторы, зоны доступности, размеры дисков и, что самое важное, пользовательские метки (labels) и теги.

!Схема интеграции: Terraform State как источник данных для Ansible

Настройка плагина Terraform Inventory

Для того чтобы Ansible научился понимать формат файла состояния Terraform, используется официальная коллекция community.general. Внутри нее находится плагин terraform.

Процесс интеграции начинается с создания конфигурационного файла инвентаря в формате YAML (например, inventory.terraform.yml). Расширение .yml и структура файла указывают Ansible, что это не статический список, а инструкция для вызова плагина.

Базовая конфигурация файла выглядит следующим образом:

Директива project_path указывает на директорию, где находится инициализированный проект Terraform (там, где лежат файлы .tf и скрытая папка .terraform). Плагин самостоятельно найдет локальный terraform.tfstate или обратится к удаленному S3-бакету, если в коде Terraform настроен backend.

Когда мы запускаем команду ansible-inventory -i inventory.terraform.yml --graph, плагин парсит JSON-дерево состояния. Он ищет все ресурсы, которые похожи на вычислительные узлы (например, yandex_compute_instance), извлекает из них атрибут network_interface.0.nat_ip_address (или внутренний IP, если работа идет в закрытом контуре) и формирует плоский список хостов. Имена хостов в Ansible будут соответствовать именам ресурсов в HCL-коде.

Однако просто получить список IP-адресов недостаточно. В реальном проекте инфраструктура гетерогенна. Базе данных PostgreSQL нужны одни настройки (тюнинг ядра, установка пакетов БД), а веб-серверу Nginx — совершенно другие. Если Ansible применит плейбук для БД к веб-серверу, система выйдет из строя.

Маршрутизация конфигураций: Динамическая группировка

В статическом инвентаре проблема решается секциями [webservers] и [dbservers]. В динамическом инвентаре группы должны формироваться алгоритмически на основе метаданных, заложенных на этапе описания инфраструктуры в Terraform.

Лучшая практика — использование меток (labels) на уровне облачного провайдера. В Yandex Cloud ресурс виртуальной машины поддерживает блок labels.

Опишем две машины в Terraform:

Теперь необходимо научить Ansible-плагин читать эти метки и распределять хосты по группам. Для этого в inventory.terraform.yml добавляется мощный механизм keyed_groups (группировка по ключу).

При парсинге State-файла плагин извлечет значения labels.role и создаст группы с заданным префиксом. Виртуальная машина frontend-proxy автоматически попадет в группы role_web и env_production. Машина backend-db окажется в role_database и env_production.

!Механика динамической группировки хостов при изменении тегов

В Ansible-плейбуках теперь можно безопасно обращаться к этим группам:

Если завтра нагрузка на веб-серверы возрастет, и через Terraform будет добавлен блок count = 3 для ресурса yandex_compute_instance.nginx, новые машины автоматически получат метку role = "web". При следующем запуске Ansible плагин прочитает обновленный State-файл, увидит три новых IP-адреса, динамически добавит их в группу role_web и раскатает на них Nginx. Ни одна строка конфигурации Ansible при этом не изменится. В этом заключается истинная мощь связки декларативной инфраструктуры и динамического инвентаря.

Управление адресацией и переменными хоста

По умолчанию плагин terraform может выбрать неправильный IP-адрес для подключения. Например, если у ВМ есть и внутренний (ip_address), и публичный (nat_ip_address) адреса, Ansible должен точно знать, какой из них использовать для SSH-соединения.

Если CI/CD Runner (или компьютер инженера) находится вне облака, подключение должно идти по публичному адресу. Если Runner находится внутри той же VPC, безопаснее и быстрее использовать внутренний адрес.

Для явного указания адреса подключения используется директива compose. Она позволяет формировать переменные хоста (host variables) на лету, вычисляя их из атрибутов Terraform.

> Директива compose работает как преобразователь (маппер) данных. Слева указывается стандартная переменная Ansible (например, ansible_host), а справа — путь к данным внутри JSON-объекта конкретного ресурса в Terraform State.

В примере выше мы не только указали адрес для подключения, но и создали кастомную переменную internal_ip. Это критически важно для настройки кластерных сервисов. Например, при настройке репликации PostgreSQL ведущий узел должен знать внутренние IP-адреса ведомых узлов, чтобы разрешить им подключение в pg_hba.conf. Благодаря compose, внутренний IP каждой машины всегда доступен в плейбуках Ansible в виде переменной.

Проблема гонки состояний (Race Condition)

При интеграции Terraform и Ansible в единый автоматизированный контур (например, в GitLab CI) возникает классическая проблема синхронизации по времени.

Пайплайн выглядит так:

  • Выполняется terraform apply.
  • Terraform сообщает об успехе сразу после того, как облачный провайдер выделил ресурсы и вернул статус RUNNING.
  • Мгновенно запускается ansible-playbook.
  • Ansible получает IP-адреса из State-файла и пытается открыть SSH-соединение.
  • Происходит ошибка Connection refused или Timeout.
  • Причина кроется в том, что статус RUNNING в облаке означает лишь то, что виртуальная машина включена. Операционная система внутри нее еще загружается. Более того, отрабатывает процесс Cloud-Init, который генерирует SSH-ключи, создает пользователей и обновляет системные пакеты. На этот процесс может уйти от 30 секунд до нескольких минут. Ansible пытается подключиться к серверу, на котором демон sshd еще не запущен.

    Решение этой проблемы лежит на стороне Ansible. В начало корневого плейбука добавляется специальная задача предварительной проверки доступности, использующая модуль wait_for_connection.

    Модуль wait_for_connection не выполняет никаких команд на удаленном сервере. Он циклично, с заданным интервалом, пытается установить TCP-соединение на порт 22 (SSH). Как только соединение успешно установлено, задача помечается как выполненная, и Ansible переходит к полноценной настройке серверов, включая сбор системных фактов (gather_facts).

    Интеграция через чтение State-файла превращает Terraform и Ansible из двух разрозненных инструментов в единый конвейер. Terraform берет на себя ответственность за физическое (виртуальное) наличие серверов, сетей и их маркировку. Ansible, выступая в роли умного потребителя этих данных, автоматически адаптирует свои действия под текущий ландшафт. Отсутствие промежуточных ручных шагов и статических файлов устраняет человеческий фактор и делает инфраструктуру по-настоящему масштабируемой и предсказуемой.

    2. Оркестрация развертывания: Provisioners и управление жизненным циклом конфигурации

    Оркестрация развертывания: Provisioners и управление жизненным циклом конфигурации

    Команда terraform apply завершилась успешно. В консоли горит зеленый текст, сообщающий о создании ресурсов. Однако виртуальная машина в облаке сейчас — это просто «голое» ядро Ubuntu. На ней нет ни Docker, ни Nginx, ни PostgreSQL. Инфраструктура существует, но сервис мертв. Возникает инженерная задача: как автоматически передать эстафетную палочку от Terraform (создавшего «железо») к Ansible (настраивающему софт) в рамках единого непрерывного пайплайна, без ручного запуска плейбуков.

    Императивный побег из декларативного мира

    Terraform спроектирован вокруг декларативной парадигмы: вы описываете желаемое состояние, а ядро само вычисляет граф зависимостей и приводит реальность к этому состоянию. Но инструменты конфигурации ОС, такие как Ansible или Bash-скрипты, по своей природе императивны — они выполняют последовательность шагов.

    Для объединения этих миров в Terraform существует механизм Provisioners (инициализаторов). Это специальные блоки кода, которые выполняют скрипты или внешние команды на локальной машине или удаленном сервере в момент создания или удаления ресурса.

    Официальная документация HashiCorp называет provisioners «крайней мерой» (last resort). Причина кроется в управлении состоянием. Terraform фиксирует в tfstate факт создания виртуальной машины, ее IP-адрес и размер диска. Но Terraform не знает, что именно сделал Ansible внутри этой машины. Если плейбук Ansible завершится с ошибкой на середине выполнения, Terraform не сможет «откатить» установку пакетов, потому что он не контролирует внутренности ОС.

    Несмотря на это предупреждение, в реальных CI/CD пайплайнах provisioners остаются стандартным способом запуска систем управления конфигурациями. Существует два основных типа инициализаторов:

  • remote-exec — подключается к созданной машине по SSH или WinRM и выполняет команды внутри нее.
  • local-exec — выполняет команду на той машине, где запущен сам процесс Terraform (например, на вашем ноутбуке или GitLab Runner).
  • Поскольку Ansible работает по push-модели и требует запуска со стороны управляющего узла, для его интеграции используется именно local-exec.

    Интеграция с Ansible через local-exec

    Самый прямолинейный способ запустить Ansible — встроить блок provisioner непосредственно внутрь ресурса виртуальной машины. Как только облачный провайдер отрапортует о создании инстанса, Terraform выполнит локальную команду.

    При on_failure = continue Terraform проигнорирует ошибку Ansible, пометит ресурс как успешно созданный и пойдет дальше по графу зависимостей. Это перекладывает ответственность за донастройку машины на плечи инженера (придется запускать Ansible вручную).

    Отвязка логики: terraform_data

    Размещение provisioner внутри ресурса yandex_compute_instance имеет серьезный архитектурный недостаток. Provisioners уровня создания (creation-time) запускаются только один раз — в момент создания ресурса. Если вы измените код Ansible-плейбука и запустите terraform apply, Terraform не увидит изменений в инфраструктуре и ничего не сделает. Чтобы принудительно накатить новую конфигурацию, вам придется вручную пометить машину как испорченную командой terraform taint (или terraform apply -replace), что приведет к ее полному пересозданию.

    Чтобы отвязать жизненный цикл конфигурации от жизненного цикла виртуальной машины, используется специальный ресурс terraform_data (в старых версиях Terraform для этого применялся null_resource).

    terraform_data не создает никаких реальных объектов в облаке. Это логическая пустышка внутри State-файла, к которой можно прикрепить provisioner и настроить триггеры для его перезапуска.

    Механика работы triggers_replace элегантна. Мы передаем туда словарь значений. В данном примере это ID виртуальной машины и криптографический хеш (SHA-256) файла плейбука. Если вы меняете код в playbook.yml, функция filesha256 вычисляет новый хеш. При запуске terraform plan Terraform видит, что значение в triggers_replace изменилось. Это заставляет его пересоздать логический ресурс terraform_data. А поскольку ресурс пересоздается, прикрепленный к нему provisioner срабатывает заново. В результате Ansible накатывает обновления на существующую виртуальную машину без ее удаления.

    Прощальный аккорд: Destroy-time Provisioners

    До сих пор мы рассматривали инициализацию при создании. Но в распределенных системах часто требуется выполнить действия перед уничтожением ресурса. Например, перед удалением виртуальной машины с базой данных нужно инициировать создание финального бэкапа, а перед удалением web-сервера — корректно вывести его из пула балансировщика нагрузки (graceful shutdown), чтобы не обрывать текущие пользовательские сессии.

    Для этого используются destroy-time provisioners. Они активируются только в момент выполнения terraform destroy или при пересоздании ресурса.

    !Последовательность вывода ноды перед удалением

    Синтаксис требует явного указания when = destroy:

    ``hcl resource "yandex_compute_instance" "worker" { # ... параметры ВМ ...

    provisioner "local-exec" { when = destroy command = "./drain_node.sh {yandex_vpc_subnet.main.id}"). Причина в том, что к моменту выполнения этого provisioner подсеть yandex_vpc_subnet.main может быть уже удалена, если граф зависимостей не выстроен жестко. Поэтому внутри destroy-provisioner разрешено использовать только объект self (ссылку на собственные атрибуты удаляемого ресурса) или count.index / each.key.

    Если destroy-time provisioner завершается с ошибкой, Terraform прерывает процесс удаления. Ресурс остается в облаке, а в State-файле он не помечается как удаленный. Это защитный механизм: если скрипт не смог корректно вывести ноду из кластера, Terraform оставляет машину работающей, чтобы инженер мог разобраться в проблеме вручную, не нарушая консистентность распределенной системы.

    Использование provisioners требует баланса. С одной стороны, они нарушают чистоту декларативного подхода, внося скрытые императивные сайд-эффекты. С другой — при грамотном применении связки terraform_data и Ansible, они превращают разрозненные инструменты в монолитный, предсказуемый конвейер развертывания, где инфраструктура и программная конфигурация становятся единым целым.