Всем привет! Меня зовут Владимир Радонец, я работаю в «Синимекс» старшим инженером службы сопровождения. В этой статье я хотел бы поделиться историей о том, как мы подружили нашего старого знакомого, HashiCorp Nomad, с runner-ами GitLab. Разумеется, предварительно я поясню, зачем нам это было нужно. Поехали.
1. Проблема: очереди и неэффективность
Некоторое время назад мы столкнулись с типовой проблемой очередей при исполнении пайплайнов. Задачи упирались друг в друга, мешая выполнять операции последовательно.
Особенно остро это ощущалось на пайплайнах для сборки статики. Задачи требовали много ресурсов, выполнялись довольно долго, но основная нагрузка приходилась не столько процессор, сколько на дисковую подсистему (IOPS). В результате у нас возникали постоянные заторы при выполнении нескольких таких задач подряд.
Таким образом, вырисовывалась такая картина маслом: несколько разработчиков одновременно пушат свои сборки — кто-то новую версию плагина, кто-то страницу сайта — и все эти задачи, каждая минут на десять, устремляются в горстку общих runner-ов. Первый в очереди, конечно, чувствует себя прекрасно. Остальные же с тоской смотрят на статус pending, а руководство, с еще большей тоской на то, как замедляется тестирование и выкатка новых фич. Условно говоря, не получалось одновременно проверить даже пять новых доработок. От попыток оптимизации, вроде кэширования, при действительно больших сборках получался лишь косметический эффект.
В итоге мы сформулировали, чего хотим достичь: на каждого пользователя в идеале должен выделяться хотя бы один runner. Далее мы прикинули, сколько примерно runner-ов нам нужно, чтобы осчастливить разработчиков и тестировщиков, ускорить их работу и сделать релизные окна чаще.
По нашему замыслу всё должно было происходить автоматически: runner должен самостоятельно появляться при возникновении потребности, корректно регистрироваться в системе, быть доступным для задач, а после их выполнения — через некоторый промежуток времени — завершать свою работу, чтобы не потреблять ресурсы впустую. Вот именно для такого управления ресурсами нам и должен был помочь Nomad, выступая в роли оркестратора. Его задача — выделять необходимые ресурсы для нашего runner, следить за их корректным использованием и возвращать обратно в систему после удаления задачи, распределяя нагрузку на ноду.
2. Требования к системе и почему HashiCorp Nomad
Осознав необходимость автоматизации, мы сформулировали требования к будущему решению.
Во-первых, минимум затрат на развертывание. Система должна была быть легкой и не требовать под себя отдельный взвод инженеров. Nomad в этом плане подходил идеально.
Во-вторых, бесшовная интеграция с существующим окружением. На тот момент это было попадание «в яблочко», потому что мы с ног до головы сидели в стеке HashiCorp. У нас уже вовсю трудились Consul (для обнаружения сервисов) и Vault (для управления секретами), да и сам Nomad уже использовался для других задач. Так что это точно не было попыткой прикрутить к телеге пятое колесо — выбор был абсолютно осознанным.
В-третьих, гибкость настройки. Нам была важна возможность шаблонизировать как сами runner-ы, так и логику их развертывания, включая политики, которые определяют, когда именно системе требуется новый runner.
И, наконец, в-четвертых, полное соответствие принципу Infrastructure as Code (IaC). Для нас было важно, чтобы весь конфиг был описан в коде. Так мы могли развернуть всё с нуля по кнопке и значительно упростили бы передачу знаний новым членам команды. Грубо говоря, с системой должны иметь возможность работать даже те, кто не был глубоко погружен в её устройство.
За безопасность отвечали многоуровневые политики: на сетевом уровне, внутри самого Nomad через RBAC и, конечно, наш верный Vault. Все секреты хранились в этом защищенном хранилище и безопасно попадали в нужные места в виде переменных окружения.
Справедливости ради отмечу, что мы все же рассматривали и другие, более стандартные и документированные варианты. Но Nomad победил, потому что компактный — его можно быстро установить и настроить. Плюс он отлично вписывался в наше окружение и существующие политики безопасности, с которыми мы уже умели работать.
Оркестрация runner-ов давала нам две важные штуки: масштабируемость и контроль. Мы получили возможность запускать задачи параллельно, а в коде была заранее описана вся конфигурация — сколько runner-ов можно поднять и с какими ресурсами. Конечно, скорость отдельной задачи могла снизиться из-за разделения ресурсов, но это легко компенсировалось добавлением в кластер новых нод. Так решалась проблема очередей, и мы намного быстрее получали результат.
3. Архитектура решения: GitLab Webhooks, Flask-сервис и Nomad Job
Итак, как же выглядела наш архитектурная реализация? Вся система состояла из трёх ключевых компонентов:
-
вебхуков в GitLab (служили триггером),
-
небольшого сервиса-обработчика на Flask («мозг»),
-
собственно, самой задачи (Nomad Job), которая разворачивала новый runner.
Всё начиналось с GitLab. Мы настроили в нём вебхуки (которые позволяли через HTTP-запросы уведомлять сторонние сервисы о событиях). Чтобы не усложнять себе жизнь, мы предварительно получили через API полный список статусов, которые умеет отдавать GitLab, и осознанно выбрали pending — состояние ожидания задачи в пайплайне. Как только задача в него переходила, GitLab отправлял на заранее указанный адрес сообщение, пример которого показан в Листинге 1.
Важный момент: мы использовали бесплатную версию GitLab CE, поэтому политики для вебхуков приходилось настраивать для каждого проекта отдельно, в то время как платные версии позволяют делать это централизованно для всего инстанса.
Листинг 1. Пример Webhook (Pipeline)
{ "object_kind": "pipeline", "object_attributes": { "id": 123, "status": "pending", "ref": "main", ... }}
Сам по себе вебхук — это просто JSON-сообщение, отправленное через POST. Чтобы от него был толк, нужен кто-то, кто это сообщение примет, расшифрует и выполнит нужные действия. Вот сюда мы и определили легковесный сервис на Python/Flask. Он решал простую, но важную задачу.
Получив JSON от GitLab, наш Flask-сервис приступал к анализу. И вот тут начинались «танцы с бубном» и «магия» одновременно. Та версия Nomad, с которой мы работали, не умела разграничивать ресурсы по пространствам имён (namespaces) или ограничивать количество экземпляров на уровне группы задач (task group). Это означало, что удобного способа сказать оркестратору «хватит, больше runner-ов не запускаем» у нас не было. Nomad с радостью запустил бы и шестую, и седьмую копию, были бы ресурсы. Эту логику пришлось вынести в наш сервис. Важно понимать: сервис не занимался оркестрацией или, боже упаси, откатами (rollback) — это оставалось вотчиной Nomad. Его единственной задачей было считать запущенные runner-ы и следить, чтобы их число не превышало жёстко заданный лимит в пять штук. Если же все пять были в работе, сервис бездействовал, а новая задача смиренно ждала своей очереди в GitLab. О любых сбоях при взаимодействии с API Nomad мы бы тут же узнали: для этого мы написали отдельный обработчик, который отправлял статусы в Alertmanager.
Листинг 2. Webhook обработчик для runner-ов
from flask import Flask, requestimport requestsapp = Flask(__name__)@app.route('/gitlab-webhook', methods=['POST'])def gitlab_webhook(): data = request.json # Логика для проверки состояния runner-ов if not check_free_runners(): # Проверяем доступность runner-ов start_new_runner() # Запускаем новый runner в Nomad если не было запущено ни одного else: scale_nomad_runners() # Увеличиваем количество runner-ов return '', 200
Сам обработчик был предельно прост. Получив POST-запрос, он вызывал функцию проверки, которая обращалась напрямую к API Nomad, чтобы узнать, не достигнут ли лимит запущенных runner-ов.
Листинг 3. Функция проверки свободных runner-ов
def check_free_runners(): # Получение информации о текущих заданиях из Nomad url = 'http://nomad.server/v1/jobs/gitlab-runner' response = requests.get(url) if response.status_code == 200: job_info = response.json() running_count = sum(task['Status'] == 'running' for group in job_info['TaskGroups'] for task in group['Tasks']) # Проверка, не превышает ли количество запущенных runner-ов 5 return running_count < 5 else: print("Превышен лимит на количество runner-ов:", response.status_code, response.text) return False
Если проверка показывала, что ресурсы есть, Flask-сервис отправлял Nomad команду на масштабирование, увеличивая количество runner-ов на один, как показано в Листинге 4. Первый запуск runner-а занимал около трёх минут, последующие — чуть быстрее. После этого мы могли наблюдать за состоянием нового runner-а уже через стандартный интерфейс Nomad.
Листинг 4. Функция масштабирования runner-ов
def scale_nomad_runners(): # Логика для изменения количества runner-ов в Nomad nomad_job_id = "gitlab-runner" # Получаем текущее количество запущенных runner-ов url = f'http://nomad.server/v1/jobs/{nomad_job_id}' response = requests.get(url) # … if response.status_code == 200: job_info = response.json() current_count = sum(task['Status'] == 'running' for group in job_info['TaskGroups'] for task in group['Tasks']) new_count = current_count + 1 # Увеличиваем количество на 1 # Отправляем запрос на изменение количества runner-ов scale_url = f'http://nomad.server/v1/jobs/{nomad_job_id}/scale' scale_response = requests.post(scale_url, json={'count': new_count}) if scale_response.status_code == 200: print(f"Увеличено количество раннеров до {new_count}") else: print("Ошибка при изменении количества runner-ов:", scale_response.status_code, scale_response.text) else: print("Ошибка при получении информации о задачах:", response.status_code, response.text)
Конфигурация runner-а и управление секретами
Итак, мы подошли к самому интересному: как именно мы настраивали наши динамические runner-ы. Обновление Nomad на тот момент было невозможно, поэтому пришлось изобрести тот самый «костыль» в виде Flask-скрипта, о котором я рассказал выше.
Самое главное — нужно было правильно описать задачу для Nomad. В Листинге 5 показан пример такой конфигурации, где мы указываем дата-центр и начальное количество экземпляров.
Листинг 5. Пример конфигурации GitLab Runner (Nomad)
job "gitlab-runner" { datacenters = ["dc1"] type = "service" group "runner" { count = 5 # Начальное количество runner-ов task "gitlab-runner" { driver = "docker" } }}
Ключевой аспект здесь — безопасная передача секретов. Мы решили эту задачу с помощью интеграции Nomad с HashiCorp Vault. Все чувствительные данные, вроде токенов, передавались через Vault-шаблон (специальный блок в описании задачи, который позволяет динамически получать секреты из Vault), как показано в Листинге 6. При старте задачи Nomad обращался к Vault, забирал оттуда нужные данные и подставлял их в виде переменных окружения.
Листинг 6. Конфигурация окружения, Vault и сервиса (Nomad)
env { GITLAB_TOKEN = "${vault.generic.secret.gitlab.token}"}vault { policies = ["gitlab-access"] change_mode = "signal" change_signal = "SIGHUP"}service { name = "gitlab-runner" port = "http" tags = ["gitlab"]}
Например, токен для регистрации runner-а мы, естественно, хранили в Vault. Тут было два пути: простой и «для тех, кто любит посложнее». Второй вариант — при каждом старте запрашивать у GitLab новый токен через API и класть его в Vault.
Мы же пошли по первому, т.е. простому пути: взяли уже существующий токен из конфигурации Shared Runner и положили его в Vault. Этого было достаточно, поскольку нам как раз требовалось, чтобы количество runner-ов и их ресурсы были чётко регламентированы. Так мы всегда понимали, насколько разрослась наша «плантация», и могли вовремя принимать решение о её расширении.
Конфигурация самого GitLab Runner пробрасывалась стандартным способом — путём монтирования конфигурационного файла config.toml внутрь окружения, где запускался runner (Листинг 7). Этот файл наполнялся не вручную, а автоматически с помощью встроенного в Nomad шаблонизатора (templater). Шаблонизатор формировал итоговый конфиг, собирая переменные как из Vault, так и из переменных окружения самой задачи Nomad. Поскольку мы использовали общий токен для регистрации (Shared Runner token), runner автоматически регистрировался в нужном проекте. Стоит отметить, что такая схема работает именно для проектных runner-ов; для приватных, где каждый токен должен быть уникальным, пришлось бы дополнительно усложнять систему.
Листинг 7. Монтирование конфигурации и команда запуска
# Монтируем конфигурационный файлvolume "gitlab-runner-config" { source = "local/gitlab-runner-config" read_only = true}mount { source = "gitlab-runner-config" destination = "/etc/gitlab-runner/config.toml" read_only = true}# Указываем команду для запуска runnercommand = ["gitlab-runner", "run", "--working-directory", "/etc/gitlab-runner"]
Изначально у нас был один общий конфигурационный файл для GitLab, который на этапе деплоя рендерился шаблонизатором. Это было не очень удобно. Со временем мы доработали этот подход и разнесли конфигурации по разным пространствам имён (namespaces) — логическим областям внутри Nomad, позволяющим изолировать задачи и их настройки. Теперь в каждом отдельном namespace запускался именно тот конфигурационный файл, который был к нему привязан. Шаблонизатор по-прежнему собирал переменные из окружения и Vault, но теперь он монтировал нужный конфиг для каждого namespace отдельно, что дало нам гораздо больше гибкости.
5. Ресурсы, масштабирование и сетевая безопасность
Теперь о самом приземлённом — о железе. Для тестового контура мы не стали требовать новых закупок, а пошли по пути разумной экономии: отыскали уже списанный, но ещё бодрый сервер. Машина была вполне приличная: 32 процессорных ядра, 128 гигабайт оперативной памяти и, что самое главное, SSD-накопители. Собирать на чём-то другом объёмы статики под 50 гигабайт было бы форменным мазохизмом, поскольку, как я уже говорил, вся производительность упиралась именно в IOPS-ы.
Мы прикинули, что для одной такой сборки нашему runner-у, запущенному в Docker, с головой хватит 8 гигабайт RAM и 4 процессорных ядра. Дальнейшее увеличение мощности CPU, как показали тесты, практически не сокращало время выполнения задачи — «бутылочным горлышком» оставался диск. Зато, запуская несколько таких исполнителей на одной физической машине, мы получали выигрыш благодаря параллельности выполнения, пусть даже каждая отдельная задача и работала чуть медленнее из-за разделения ресурсов.
Конечно, настоящий прирост производительности даёт только горизонтальное масштабирование. Изначально мы описывали этот подход как способ «просто поиграться» и быстро что-то протестировать, когда другие варианты недоступны. То есть мы добавляли в стойку новую физическую машину, включали её в кластер Nomad, регистрировали в нужном дата-центре (DC), навешивали стандартные метрики для мониторинга — и всё, система готова была её использовать. Nomad при необходимости перезапускает ноды в кластере по отдельности, автоматически их выбирая, что не влияет на доступность кластера или конкретного дата-центра в целом.
Правда, это «всё» потребовало доработки нашего управляющего Flask-сервиса. Если раньше в нём было жёстко прописано ограничение в пять runner-ов на один сервер, то с появлением новых машин скрипту пришлось «поумнеть». Мы доработали его так, чтобы он мог принимать решение, в каком именно дата-центре запустить новый экземпляр. Это было важно, чтобы не создавать очередь уже на уровне дисковой подсистемы одной-единственной машины. Запуск нескольких задач, активно работающих с диском, на одной ноде — верный путь к деградации производительности для всех.
Следующим логичным шагом стала настройка сетевой безопасности. У нас были разные типы сборок: одни, как сборка статики, должны были работать в полностью изолированном окружении без доступа во внешний мир. Другим, наоборот, требовался доступ в интернет для скачивания пакетов и зависимостей. Решать эту задачу «в лоб», создавая разные типы виртуальных машин, было бы накладно.
Выход нашёлся в использовании политик самого Nomad. Мы создали два разных шаблона для runner-ов: с доступом в интернет и без. Эти политики привязывались к задачам на уровне namespace. Наш Flask-сервис, получая вебхук, теперь не просто проверял лимиты, но и определял, какой тип runner-а нужен, и запускал его с нужной политикой. В той старой версии Nomad, кстати, не было возможности управлять привилегиями безопасности контейнеров через политики, поэтому нужные разрешения приходилось вручную прописывать в конфигурации самого runner-а. Современные версии элегантно решают это через сетевые политики и всё те же namespaces.
6. Сравнение с альтернативами: Kubernetes, Terraform, Ansible
Думаю, у многих уже вертится на языке вопрос: зачем было городить огород с Flask, когда есть Kubernetes? Вопрос справедливый, и мы много раз обсуждали его в команде. Мы, естественно, смотрели на «нормальный» вариант, предусмотренный самим GitLab, где интеграция с K8S даёт оркестрацию runner-ов из коробки. Но весомых причин для миграции так и не нашли. Да, по сравнению с Kubernetes в Nomad многого не хватает, это правда.
Однако его функционал можно дорабатывать и кастомизировать под свои нужды, а для нашей конкретной задачи — оркестрации runner-ов — возможности обоих инструментов оказались примерно схожими. Это и стало одним из аргументов в пользу сохранения статус-кво. Тащить в устоявшуюся экосистему Kubernetes только ради одной этой задачи было бы избыточно, а Nomad уже был частью инфраструктуры, был легок и требовал ощутимо меньше ресурсов для развертывания.
Рассматривали ли мы что-то ещё? Да, конечно. Например, Terraform был бы очень хорошим вариантом для создания виртуализированного окружения, где он бы сам поднимал виртуалки под runner-ы. Он довольно близок по философии к нашему стеку, и разобраться в его конфигурации несложно.
А вот Ansible мы обошли стороной. Хотя он и был у нас в стеке, но глубоко погруженного в него специалиста в команде не нашлось. Нашей целью было решить задачу с минимальными затратами ресурсов, в том числе и человеческих. Поскольку у нас уже были специалисты по стеку HashiCorp, логично было двигаться в этом направлении. Шаблоны Ansible показались нам на тот момент несколько сложнее, чем конфигурации Terraform или Nomad.
Планируем ли мы в итоге переходить на Kubernetes? Наверное, нет. Если всё-же решимся мигрировать в облака, например, в то же Яндекс.Облако, то там мы, скорее всего, будем использовать их готовые Shared GitLab Runner. А это, по сути, и есть обёртка над Kubernetes, так что к нему мы так или иначе придём, но уже в рамках более глобального переезда.
7. Уроки, улучшения и ответы на технические вопросы
Теперь, когда пыль улеглась, можно оглянуться назад и подвести итоги. Какие уроки мы извлекли и что бы сделали иначе, начни мы сегодня? А заодно отвечу на пару технических вопросов, которые нам часто задают.
Представим, что вы начинаете с нуля, без уже развернутого и настроенного окружения, как было у нас. Насколько это облегчит решение? Для начала понадобится сам сервер, на который из бинарника разворачивается Nomad. Далее надо будет настроить необходимые политики, дата-центры (DC) и подкинуть ему наши ноды, которые будут с ним работать. Если вы, как и мы в то время, используете старую версию Nomad, то придётся установить и настроить интеграцию с Consul, потому что собственного механизма Service Discovery у него тогда не было.
Потом — с безопасностью — тоже складывалась интересная ситуация. По умолчанию веб-интерфейс Nomad был открыт для всех, у кого есть учётка, поэтому нужно настраивать отдельные политики доступа к нему, а также к дата-центрам. На этом же этапе крайне желательно интегрировать Nomad с Vault для управления секретами. Наконец, для нашего Flask-сервиса нужно создать отдельную политику с доступом к API Nomad, а его учётные данные, конечно же, хранить в Vault — так безопаснее и удобнее. Если делать всё вдумчиво и в первый раз, на это может уйти день-два. Конечно, сейчас с помощью агентов Nomad можно развернуть за двадцать минут, но для интеграции в существующую инфраструктуру с требованиями по безопасности придётся потрудиться.
Кстати, об интеграции с GitLab. По сути, для неё нужен только пользователь, который может через наш Flask-сервис обращаться к Nomad. Так что здесь всё просто: настраиваем вебхук в GitLab и учётную запись для Flask. Никакой магической магии.
Поскольку эту статью я писал по мотивам своего доклада на паре митапов, озвучу еще несколько заданных мне вопросов.
Где вы запускаете сам Flask-скрипт? Он у нас хранился на отдельной машине, которую можно условно назвать API Gateway. Там у нас живут все подобные сервисы, и GitLab обращается к нему по сети.
Почему перестали использовать Levant? Изначально мы действительно использовали Levant для шаблонизации задач в Nomad. Но он был неудобен. Каждый раз при обновлении Nomad приходилось обновлять и Levant, причём иногда случалось так, что новая версия оркестратора ещё не поддерживалась шаблонизатором. Из-за этих проблем с совместимостью мы от него отказались в пользу стандартного встроенного механизма. Пришлось немного переписать шаблоны, но зато теперь всё работает в одном ключе и более надёжно.
А почему вы не использовали встроенный автоскейлер Nomad? На тот момент в нашей версии его, по сути, не было. Мы работали с одной из ранних версий, где отсутствовал даже Service Discovery, так что многое приходилось дописывать своими руками.
8. Заключение: оправданный «велосипед»
В итоге у нас получилась конструкция, которую в приличном обществе принято называть «велосипедом». Да, это не самый очевидный путь, и нам пришлось его изобретать. Но этот выбор был полностью оправдан нашей инфраструктурой: у нас уже был развёрнут и обкатан стек HashiCorp, а тащить в эту экосистему Kubernetes только ради одной, пусть и важной, задачи было бы… ну, скажем так, причудой.
Миграция на Kubernetes выглядит простой, когда начинаешь с чистого листа. Совсем другая история — перетаскивать на новую платформу устоявшуюся инфраструктуру со множеством продуктовых компонентов. Решение такой задачи потребовало бы выделить человека, время, а в итоге — деньги.
Поэтому проще было доработать уже имеющийся и понятный инструмент под нашу задачу — избавиться от очередей. Получился вполне себе рабочий компромисс между «сердито» и «дешево».
В принципе, на этом всё. Спасибо за внимание. Если было полезно, подписывайтесь на наш хаб.
ссылка на оригинал статьи https://habr.com/ru/articles/1031360/