Генри Форд, диалектический материализм и при чем здесь автоматическая подготовка машин в облаке
Любовь это… Когда она смотрит со слезами на глазах на твой огромный… Автоматически масштабируемый кластер в облаке
Лучшая машина - новая машина!
В этой статье мы расскажем, как научились делать автоматическую подготовку виртуальных машин в облаке. Мы - простые разработчики и DevOps инженеры, уже не представляем свою жизнь без конвейерной и контейнерной доставки приложений, но теперь пришло время пойти дальше и внедрить конвейер и в инфраструктуре. Аналогично тому, как Генри Форд стал впервые использовать конвейер для поточного производства автомобилей (первый конвейер был запущен в 1913 г. для сборки генераторов), точно также DevOps инженеры спустя ровно 100 лет (первый выпуск docker 13 марта 2013г.) стали использовать аналогичную технологию конвейерной сборки и поточной доставки приложений в продуктовое (и не только) окружение.
Генри Форд начал массово производить авто ("Автомобиль для всех"), что стало апогеем индустриальной эпохи, а спустя 100 лет массово начали производить ПО ("ПО в каждый карман" прим. автора), что стало апогеем постиндустриальной эпохи, что плавно приводит нас к принципу развития в диалектическом материализме… Но, пожалуй, давайте перейдем к технической части.
Мы уже рассказывали в прошлых статьях о нашей облачной инфраструктуре. Раз , два , три и даже четыре. Пришло время двигаться дальше. В нашем облаке уже достаточно много машин (чуть больше 20). И мы, когда разворачивали их, делали все это через ansible и статический inventory файл, добавляли туда адреса, добавляли машину в нужные группы, чтобы на машине разворачивались нужные контейнеры из разных групп, мониторинг, логирование и т.д. И нам это уже изрядно надоело. Задача: при масштабировании группы виртуальных машин в облаке при старте машины, не править inventory файл, не запускать на машине руками нужные плейбуки, а как-то это все автоматизировать.
1. Подготовка
- Группу виртуальных машин в Yandex Cloud.
- Консольную утилиту yc для работы с API Yandex Cloud.
- Workflow Engine n8n или любой другой программируемый обработчик webhook’ов.
- cloud-init - инструмент для настройки облачных серверов.
- 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 о том, что инстанс подготовлен и готов к работе.
3. Итоги
В результате теперь нам достаточно просто исправить одну цифру: кол-во инстансов в интерфейсе Яндекс облака (да-да, мы знаем про существование terraform, но не все же сразу), которое требуется нам для работы, а всё остальное произойдет автоматически. Теперь мы можем получить в облаке много новых машин за очень короткое время. Можно пойти еще дальше и воспользоваться автоматическим масштабированием, но мой техлид больше всего боится "когда на автоматическом сливном бачке нет ручной кнопки «слив»", так что этот вопрос придется немного отложить.