Представьте: вы разработчик программного обеспечения в крупной компании и вам потребовалось создать новую виртуальную машину. Как это сделать? Пожалуй, многие ответят, что можно запросить ее через отдельную задачу на ответственную команду DevOps-инженеров, потом подождать, пока за нее возьмутся и вручную создадут ВМ с помощью OpenStack.

Звучит просто, но это только часть пути. От глаз разработчика скрыты шаги по добавлению ВМ в inventory, определению нужных конфигурационных параметров, прогону ansible-ролей и сопутствующей настройке. Иногда и на этом работа не заканчивается, ведь люди привыкли пользоваться доменными именами, а не ip-адресами. Вручную этот процесс занимает много времени и не лишен влияния человеческого фактора, поэтому возникает необходимость в автоматизации.

Для работы с OpenStack удобно использовать Terraform. Хотя компания Hashicorp прекратила свою деятельность на территории России, нам все еще доступен open source-форк под говорящим названием OpenTofu. К сожалению, достаточно подробной инструкции по работе с ВМ через OpenTofu на просторах интернета найти не удалось, поэтому я и решил создать ее сам, сделав акцент на широте возможностей инструмента.

Чего мы хотим добиться

Ничто так не мотивирует, как финальный результат, поэтому давайте сразу проговорим, к чему именно мы стремимся. А стремимся мы получить автоматизированный механизм создания виртуальных машин на основе текстовых файлов конфигурации. Схема работы примерно такая:

  1. В GitOps-репозитории находятся terraform-файлы с описанием параметров виртуальных машин и DNS.

  2. На основе этих файлов формируются конфигурации для новых ВМ.

  3. Все изменения вносятся через pull request, а их корректность проверяется автоматическими запусками тестов в CI.

  4. После слияния изменений CI запускает процесс приведения нового желаемого состояния репозитория к действительному (повеяло Kubernetes, не так ли?).

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

Инструментарий

Как уже было упомянуто, для создания и настройки виртуальных машин мы будем использовать OpenTofu. Этот инструмент позволяет описывать инфраструктуру в декларативном формате и автоматически применять изменения. OpenTofu использует провайдеры — плагины, которые взаимодействуют с облачными платформами, физическими серверами и другими ресурсами. Далее мы задействуем провайдеры для работы с OpenStack и DNS.

Создание виртуальных машин

В подходе, который я предлагаю, создание ВМ происходит через описание ее параметров в конфигурационном файле — vm.tf. Рассмотрим пример такой конфигурации:

resource "openstack_compute_instance_v2" "resource-name" {
  name        	    = "vm-name" # Имя VM
  flavor_name 	    = "flavor_name" # Шаблон для создания VM
  key_pair        	= "key_pair" # Пара ssh-ключей
  availability_zone = "zone_name" # Необязательный параметр зоны доступности
  tags          	= ["some_tag"] # Необязательный список тегов
 
  block_device {
    uuid              	  = "image-uuid" # Уникальный id образа
    source_type       	  = "image" # Исходный тип устройства
    volume_size       	  = size # Размер создаваемого тома. Совпадает с объемом flavor
    volume_type       	  = "volume_type" # lvm_ssd или lvm_hdd
    boot_index        	  = 0 # Необязательный индекс загрузки тома
    destination_type  	  = "volume" # Тип создаваемого тома
    delete_on_termination = true # Удаление тома при завершении экземпляра
  }
 
  network {
    name = "network_name”
  }
}

Самые непонятные здесь переменные, на мой взгляд, — flavor_name и uuid. Узнать их возможные значения можно в веб-интерфейсе OpenStack или с помощью команды CLI-интерфейса openstack flavor list.

Есть причина указывать UUID, а не просто ссылаться на шаблон по имени: мы описываем конфигурацию тома для виртуальной машины, чтобы она создавалась не на локальном хранилище, а на указанной Availability Zone. Указание имени образа не предусмотрено для создания тома. В моем примере указаны не все возможные переменные, используемые для настройки ВМ, а только те, что актуальны для нас. Более подробную информацию можно найти в официальной документации, а о создании новых flavor, шаблонов виртуальных машин, поговорим чуть позже.

Конфигурация созданных машин

После создания «голой» ВМ часто требуется ее дополнительная настройка, так как разработчики могут использовать ВМ для разных целей. Для настройки мы используем Ansible. В моей реализации можно сразу определить, какие роли Ansible будут применены для созданной виртуальной машины.

Для запуска Ansible playbook я решил использовать local-exec provisioner, который позволяет запускать команды через оболочку системы. Прежде чем перейти к описанию реализации, нужно немного рассказать, как Ansible может работать с OpenStack. Мы используем динамический inventory, который формируется с помощью коллекции openstack.cloud. В inventory попадают все ВМ из кластера OpenStack. Для формирования групп используются теги, которые указывают при создании ВМ. Таким образом, достигается соответствие между группами из статического и динамического inventory и происходит правильное применение всех групповых переменных, описанных в статическом inventory. Более подробно можно почитать в документации.

Перейдем непосредственно к настройке ВМ. Конфигурационный файл достаточно легко читается, но для удобства я дополнил его пояснениями. Он обеспечивает прогон плейбука init.yml, состоящего из ряда ролей для первоначальной настройки «голой» виртуальной машины:

resource "null_resource" "resource-name-init" {
  provisioner "local-exec" {
    # Команда специфична для запускаемой роли
    command = <<-EOF
      ansible-playbook -i $ANSIBLE_ROLES_REPO/inventory/hosts -i $ANSIBLE_ROLES_REPO/inventory/openstack.yml \
      --limit ${openstack_compute_instance_v2.vm-name.name} --user ubuntu --private-key $SSH_PRIVATE_KEY \
      $ANSIBLE_ROLES_REPO/init.yml --vault-password-file $ANSIBLE_VAULT_PASSWORD_FILE
    EOF

    working_dir = var.ansible_roles_path # Рабочая директория для запуска роли

    # Переменные окружения, необходимые роли
    environment = {
      ANSIBLE_ROLES_REPO          = var.ansible_roles_path
      ANSIBLE_VAULT_PASSWORD_FILE = var.ansible_vault_password_file
      ANSIBLE_CONFIG              = "${var.ansible_roles_path}/ansible.cfg"
      SSH_PRIVATE_KEY             = var.private_key_path
    }
  }
  # Зависимость от ресурса, для которого запускается роль, обязательна к указанию
  depends_on = [
    openstack_compute_instance_v2.tofu-test-vm-1
  ]
}

Запуск дополнительных ролей

Хотя для большей части виртуальных машин обычно достаточно базовой настройки, иногда возникает необходимость в запуске дополнительных ролей. Для реализации такого функционала будем использовать triggers. Предположим, что нужно обеспечить базовую настройку виртуальной машины, а еще добавить ее в систему мониторинга. Тогда конфигурационный файл будет выглядеть так:

resource "null_resource" "vm-init" {
  # Запуск роли init как описано выше
}

resource "null_resource" "vm-monitoring" {
  # Запуск роли monitoring после прогонки роли init 
  triggers = {
    order  = null_resource.vm-init.id
  }
}

Создание DNS-записи

Как уже обсуждалось, мы бы хотели обращаться к особо важным виртуальным машинам по доменным именам, а не ip, в том числе на случай смены ip-адреса. В нашем кластере OpenStack настроено создание DNS-записей вида vm-name.cluster.company.com, а для удобства конечных пользователей мы создаем CNAME-записи вида vm-name.company.com.

Через провайдер с лаконичным и понятным названием dns опишем создание CNAME:

resource "dns_cname_record" "example-vm" {
  zone  = "company.com."
  name  = "example-vm"
  cname = "example-vm.cluster.company.com."
  ttl   = 3600
}

Безусловно, никто не запрещает создавать и А-записи:

resource "dns_a_record_set" "example-vm" {
  zone      = "company.com."
  name      = "example-vm"
  addresses = [
     "10.135.16.215",
  ]
  ttl       = 3600
}

Конфигурация сетевого трафика

Когда необходимо особым образом настроить входящий (ingress) или исходящий (egress) трафик, оказывается, что делать это через веб-интерфейс крайне долго, особенно если правил десятки. К счастью, это можно автоматизировать: достаточно описать ресурсы и параметры security-групп. Пример такой конфигурации:

resource "openstack_networking_secgroup_v2" "some_secgroup" {
  name    	  = "some_name"
  description = "Security group for something"
}
resource "openstack_networking_secgroup_rule_v2" "some_secgroup_rule_1" {
  direction     	= "ingress"
  ethertype     	= "IPv4"
  protocol      	= "tcp"
  port_range_min	= 6443
  port_range_max	= 6443
  remote_ip_prefix  = "0.0.0.0/0"
  # id группы безопасности, которой принадлежит правило
  security_group_id = openstack_networking_secgroup_v2.some_secgroup.id
}

Больше информации можно найти в документации.

Создание шаблонов виртуальных машин

Создание шаблонов виртуальных машин, также называемых flavors, через Web UI занимает продолжительное время, но и это мы способны автоматизировать. Кому-то это может показаться излишним усложнением, но представьте, что требуется создать пять или десять типовых шаблонов. В таком случае простой текстовый формат оказывается предпочтительнее кнопок в интерфейсе, да и иметь воспроизводимую конфигурацию в виде кода приятно в принципе. Посмотрим на пример конфигурации flavor.tf:

resource "openstack_compute_flavor_v2" "flavor-name" {
  name  	= "flavor-name"
  ram   	= "ram_size_mb"
  vcpus 	= "cpu_count"
  disk  	= "disk_size_gb"
  flavor_id = integer_id
  is_public = true
}

За дополнительными деталями снова отсылаю к документации.

Настройка провайдеров

Увы, для получения параметров настройки самого провайдера все-таки придется воспользоваться веб-интерфейсом OpenStack. Среди параметров есть и чувствительные данные, такие как пароль. Их мы храним в переменных OpenTofu и передаем из секретов в инструменте CI. Готовая конфигурация может выглядеть примерно так:

terraform {
 required_version = ">= 0.14.0"
 required_providers {
   openstack = {
     source  = "terraform-provider-openstack/openstack"
     version = "~> 3.0.0"
   }
 }
}

provider "openstack" {
 auth_url          = var.os_auth_url
 tenant_name       = var.os_project_name
 user_domain_name  = var.os_user_domain_name
 project_domain_id = var.os_project_domain_id
 user_name         = var.os_username
 password          = var.os_password
 region            = var.os_region_name
}

terraform {
 required_version = ">= 0.14.0"
 required_providers {
   dns = {
     source  = "hashicorp/dns"
     version = "3.4.2"
   }
 }
}

provider "dns" {
 update {
   server        = "DNS server"
   key_name      = "dns-key."
   key_algorithm = "hmac-sha256"
   key_secret    = var.key_secret
 }
}

Как происходит обновление

Стоит сказать пару слов о том, как вообще OpenTofu понимает, что произошли изменения и нужно что-то сделать. Все созданные ресурсы и их конфигурация хранятся в виде json-файла состояния с именем tfstate. При каждом изменении новая версия конфигурации сравнивается с версией из файла состояния, и если они различаются, то выполняется синхронизация состояния.

Кстати, из этого следует, что для удаления ВМ нужно всего лишь убрать ее конфигурацию из соответствующих файлов, удалить блоки, связанные с ролями для этой ВМ, и конфигурацию DNS-записи — то есть все то, что мы создавали выше.

Итоговая конфигурация

Могу вас поздравить, коллеги. Мы получили ачивку репозиторий с примерно такой структурой директорий. Безусловно, это не жесткие правила именования, но я нахожу выбранные имена достаточно подходящими к выполняемым задачам:

configs/
├── openstack/
│ ├── vm.tf # Конфигурация ВМ
│ ├── ansible.tf # Конфигурация ansible-ролей
│ ├── secgroups.tf # Security Groups и правила
│ ├── flavor.tf # Конфигурация flavors для ВМ
│ ├── providers.tf # Провайдер для работы с OpenStack
│ └── variables.tf # Переменные
└── dns/
├── records.tf # Конфигурация DNS записей
├── providers.tf # Провайдер для работы с DNS
└── variables.tf # Переменные

Валидация конфигураций

Напоследок опишу, как мы проверяем, что изменения, вносимые через pull request, корректны:

  • TFLint — официальный линтер Terraform для проверки качества кода.

  • tofu plan — команда, проверяющая возможность применения конфигурации. Важно понимать, что эта команда никак не проверяет корректность, а только факт указания всех обязательных параметров.

  • Проверка синхронизации динамического и статического inventory — кастомный скрипт, который проверяет, что в статическом inventory присутствуют все группы хостов из динамически генерируемого inventory. Если это не так, пользователю предлагается создать отсутствующие группы.

Конечно же, все эти проверки запускаются автоматически при открытии pull request, не требуя ручного вмешательства.

Заключение

То, чего нам удалось достичь, — это самая верхушка айсберга. OpenTofu предоставляет мощнейший инструментарий для автоматизации самых разных задач. Внедрение GitOps для виртуальных машин уже сейчас экономит огромное количество времени, но это не повод останавливаться на достигнутом. Управление базами данных, установка операционных систем и другие рутинные задачи обязательно будут автоматизированы, и поможет нам в этом именно GitOps.

Комментарии (2)


  1. frenzon
    18.02.2025 09:21

    Спасибо! Очень познавательно. Обязательно добавлю в закладки.


  1. TrampIka
    18.02.2025 09:21

    Очень интересная статья, с нетерпением буду ждать продолжения!