infra
May 6

Генри Форд, диалектический материализм и при чем здесь автоматическая подготовка машин в облаке

Любовь это… Когда она смотрит со слезами на глазах на твой огромный… Автоматически масштабируемый кластер в облаке

Демин Евгений

Лучшая машина - новая машина!

Генри Форд

0. Введение

В этой статье мы расскажем, как научились делать автоматическую подготовку виртуальных машин в облаке. Мы - простые разработчики и DevOps инженеры, уже не представляем свою жизнь без конвейерной и контейнерной доставки приложений, но теперь пришло время пойти дальше и внедрить конвейер и в инфраструктуре. Аналогично тому, как Генри Форд стал впервые использовать конвейер для поточного производства автомобилей (первый конвейер был запущен в 1913 г. для сборки генераторов), точно также DevOps инженеры спустя ровно 100 лет (первый выпуск docker 13 марта 2013г.) стали использовать аналогичную технологию конвейерной сборки и поточной доставки приложений в продуктовое (и не только) окружение.

Генри Форд начал массово производить авто ("Автомобиль для всех"), что стало апогеем индустриальной эпохи, а спустя 100 лет массово начали производить ПО ("ПО в каждый карман" прим. автора), что стало апогеем постиндустриальной эпохи, что плавно приводит нас к принципу развития в диалектическом материализме… Но, пожалуй, давайте перейдем к технической части.

Мы уже рассказывали в прошлых статьях о нашей облачной инфраструктуре. Раз , два , три и даже четыре. Пришло время двигаться дальше. В нашем облаке уже достаточно много машин (чуть больше 20). И мы, когда разворачивали их, делали все это через ansible и статический inventory файл, добавляли туда адреса, добавляли машину в нужные группы, чтобы на машине разворачивались нужные контейнеры из разных групп, мониторинг, логирование и т.д. И нам это уже изрядно надоело. Задача: при масштабировании группы виртуальных машин в облаке при старте машины, не править inventory файл, не запускать на машине руками нужные плейбуки, а как-то это все автоматизировать.

1. Подготовка

Для всего этого мы возьмем:

  1. Группу виртуальных машин в Yandex Cloud.
  2. Консольную утилиту yc для работы с API Yandex Cloud.
  3. Workflow Engine n8n или любой другой программируемый обработчик webhook’ов.
  4. cloud-init - инструмент для настройки облачных серверов.
  5. ansible + динамический inventory в виде скрипта на python.

Из всех ингредиентов самый неизвестный это, пожалуй, n8n, стоит сказать пару слов об этом инструменте. Если вкратце, n8n - это флэт-уайт в мире workflow engines. Мы смотрели на другие аналоги: huginn, temporal, dagster, windmill, apache airflow. Из всех вышеперечисленных workflow engines n8n оказался самым симпатичным визуально, а также при беглом первом взгляде достаточным, чтобы удовлетворить наши потребности.

2. Deploy и автоматическая подготовка

Итак, наша схема работы и провижининга машин выглядит следующим образом:

Схема работы автоматической подготовки машины в облаке
При запуске новой машины она (через cloud-init) шлёт POST-запрос со своими данными в Webhook Engine (n8n), а там с помощью динамического инвентаря на этой новой машине катается всё необходимое для включения машины в кластер.

ansible

Cкрипт на python, который будет формировать наш динамический inventory:

#!/usr/bin/env python3

import json, os, sys, subprocess, argparse, yaml
from collections import namedtuple

# хз какая там будет версия питона, но я бы заменил на dataclasses
MachineName = namedtuple("MachineName", ['name', 'zone_id'])

class MachineInfo:

  NOMAD_META_PREFIX = 'nomad_'
   
  def __init__(self, raw_data):
    self._raw_data = raw_data


  @property
  def name(self):
    return self._raw_data['name']

  @property
  def ip(self):
    return self._raw_data['network_interfaces'][0]['primary_v4_address']['address']

  @property
  def metadata(self):
    return self._raw_data.get('metadata', {})

  @property
  def roles(self):
    roles = self.metadata.get('roles', '').split(',') + self.metadata.get('role', '').split(',')
    return list(set(filter(None, roles)))
  
  @property
  def nomad_metadata(self):
    return {k:v for k,v in self.metadata.items() if k.startswith(self.NOMAD_META_PREFIX)}

  @property
  def zone_group(self):
    return f"zone_{self._raw_data['zone_id'][-1]}" 

  @property
  def user(self):
    ud = yaml.safe_load(self.metadata['user-data'])
    return ud['users'][0]['name']

class YClient:
    
  def list_machines(self):
    result = self._execute_cmd("compute", "instance", "list")
    return list(MachineName(name=it['name'], zone_id=it['zone_id']) for it in result)

  def get_machine_info(self, machine_name: str):
    result = self._execute_cmd("compute", "instance", "get", "--full", machine_name)
    return MachineInfo(result)
  
  def _execute_cmd(self, *command):
    if not command:
      raise ValueError("Empty command")
    if command[0] != "yc":
      command = ["yc", *command]
    if "--format" not in command:
      command.extend(["--format", "json"])
    output = subprocess.check_output(command)
    return json.loads(output)


def build_ansible_definition(client, host_filter):
  result = {
     "_meta": {
        "hostvars": {}
     }
  }
  hostvars = result['_meta']['hostvars']
  for it in client.list_machines():
    if host_filter and it.name != host_filter:
      continue
    machine_info = client.get_machine_info(it.name)
    vars = hostvars[machine_info.name] = machine_info.nomad_metadata
    vars['ansible_host'] = machine_info.ip
    vars['ansible_ssh_host'] = machine_info.ip
    vars['ip'] = machine_info.ip
    vars['ansible_ssh_user'] = machine_info.user

    for role in machine_info.roles:
      role_data = result[role] = result.get(role, {})
      role_data['hosts'] = role_data.get('hosts', []) + [machine_info.name]
      role_data['vars'] = {"role": role}

    zone = machine_info.zone_group
    zone_data = result[zone] = result.get(zone, {})
    zone_data['hosts'] = zone_data.get('hosts', []) + [machine_info.name]
    zone_data['vars'] = {"zone": zone}

    all_data = result['all'] = result.get('all', {})
    all_data['hosts'] = all_data.get('hosts', []) + [machine_info.name]
    all_data['vars'] = {}

  return result


parser = argparse.ArgumentParser()
parser.add_argument(
  "--host",
  help="Display vars related to the host"
)

parser.add_argument(
  "--list",
  action="store_true",
  help="Show JSON of all managed hosts"
)

if __name__ == '__main__':
  args = parser.parse_args()
  client = YClient()
  if args.host:
    definition = build_ansible_definition(client, args.host)
  else:
    definition = build_ansible_definition(client, os.environ.get('INSTANCE_NAME', None))
  json.dump(definition, sys.stdout, indent=2, ensure_ascii=False)

Скрипт собирает нам JSON из данных, которые получает по API через утилиту yandex cli, если хотите узнать, какой формат должен быть у inventory в формате JSON, то из статического inventory можно сделать экспорт в json следующей командой

ansible-inventory -i inventory/hosts.ini –list > ./inventory.json

packer

C помощью packer создали образ виртуальной машины, конфиг (не полностью) образа packer далее

.......
source "yandex" "autogenerated_1" {
  disk_type           = "network-hdd"
  folder_id           = "${var.folder_id}"
  image_description   = "debian 12 image with docker and ansible and run CLOUD-INIT through n8n"
  image_family        = "debian"
  image_name          = "debian-12-base-cloud-init-n8n-${local.timestamp_image}"
  disk_size_gb        = 50
  source_image_family = "debian-12"
  ssh_username        = "debian"
  subnet_id           = "std4dhhhrtef3"
  token               = "${var.token}"
  use_ipv4_nat        = true
  zone                = "ru-central1-a"
}

build {
  sources = ["source.yandex.autogenerated_1"]

  provisioner "file" {
    source = "10_autoprepare_instance.cfg"
    destination = "/tmp/10_autoprepare_instance.cfg"
  }

  provisioner "file" {
    source = "hostname.json"
    destination = "/tmp/provision_params.json"
  }

  provisioner "shell" {
    inline = ["sudo apt-get update -y", "sudo apt-get install -y ansible python3 python3-pip apt-transport-https ca-certificates curl gnupg-agent software-properties-common mtr-tiny traceroute mc vim git rsync nload strace tmux dnsutils tcpdump htop jq gettext-base",
              "sudo mv /tmp/10_autoprepare_instance.cfg /etc/cloud/cloud.cfg.d/10_autoprepare_instance.cfg",
              "sudo mv /tmp/provision_params.json /opt/provision_params.json",
  .......

Тут самое главное это provision_params.json , содержащий все необходимые данные для провижининга (оказалось, что достаточно только hostname):

{"HOSTNAME”:”META_HOSTNAME"}

и 10_autoprepare_instance.cfg  - cloud-init конфиг.

cloud-init

Конфиг/скрипт 10_autoprepare_instance.cfg будет запускаться на виртуальной машине при ее первом старте:

#cloud-config
---

package_update: true
package_upgrade: true


users:
  - name: cloudname
    shell: /bin/bash
    ssh_authorized_keys:
      - ssh-rsa esntastoDasentarstANzaC1yc2EAAAABA
    sudo: ALL=(ALL) NOPASSWD:ALL
 
runcmd:
  - sudo cp /opt/provision_params.json /home/cloudname/provision_params.json 
  - export META_HOSTNAME=$(curl -H Metadata-Flavor:Google 169.254.169.254/computeMetadata/v1/instance/?recursive=true | jq .name | tr -d '"')
  - 'sudo sed -i "s/META_HOSTNAME/$META_HOSTNAME/g" /home/cloudname/provision_params.json'
  - 'sleep 1'
  - 'curl -s -X POST -H "Content-type: application/json" -d @/home/cloudname/provision_params.json http://URL_N8N_INSTNACE/webhook/87d66f08-ca37-4404-85b4-d9defc114'

Тут мы просто создаем файл (упс, что-то не получилось сразу сделать JSON в строке с curl, чтобы cloud-init был доволен при старте инстанса, поэтому через файл) provision_params.json с hostname нашего инстанса и передаем его в POST запросе, который отправляется в n8n, далее n8n запускает ansible-playbook c нашим динамическим inventory и делает provisioning инстанса в облаке, после окончания наш инстанс находится в кластере consul, nomad и готов принимать полезную нагрузку.

n8n

Задеплоили n8n на машине внутри нашего облака. И в стиле nocode (ну почти) в его интерфейсе сделали следующий workflow:

  • точка входа - это webhook;
  • в шаге Execute Command мы запускаем ansible-playbook -i apps.py playbooks/do_everything_in_the_best_way.yml;
  • после успешного provisioning отправляем уведомление в Telegram о том, что инстанс подготовлен и готов к работе.
Workflow at n8n

3. Итоги

В результате теперь нам достаточно просто исправить одну цифру: кол-во инстансов в интерфейсе Яндекс облака (да-да, мы знаем про существование terraform, но не все же сразу), которое требуется нам для работы, а всё остальное произойдет автоматически. Теперь мы можем получить в облаке много новых машин за очень короткое время. Можно пойти еще дальше и воспользоваться автоматическим масштабированием, но мой техлид больше всего боится "когда на автоматическом сливном бачке нет ручной кнопки «слив»", так что этот вопрос придется немного отложить.