В Arenadata мы используем Jenkins для CI. Почему? Как бы банально это ни звучало — так исторически сложилось. Мы хранили код в GitHub, когда там ещё не было Actions, и продолжаем хранить, потому что много работаем с Open Source. За три года работы с Jenkins мы неплохо разобрались в нём, в том числе научились быстро масштабироваться, чтобы удовлетворять запросы разработки. В этой статье хочу поделиться тем, что мы успели понять про разные способы балансировки нагрузки в Jenkins. Если вам это близко, добро пожаловать под кат.
Для тех, кто давно эксплуатирует Jenkins и кому проблемы, связанные с его эксплуатацией, набили оскомину, сразу напишу список того, о чём я не буду рассказывать в этой статье:
как быстро обновлять плагины Jenkins и не сломаться;
как следить за адом зависимостей плагинов;
что делать, если наш плагин перестали поддерживать (да и надо ли это обсуждать, речь ведь про Open Source).
Речь пойдёт о том, какими способами мы решали задачу балансировки нагрузки в Jenkins и что из этого получилось.
Небольшие вводные: Jenkins — это фреймворк автоматизации, написанный на языке Java. Понятно, что для успешного использования любого фреймворка неплохо бы владеть языком, на котором этот фреймворк написан, но где вы видели отделы DevOps, которые умеют писать на Java? Вот и наша DevOps-команда на Java не пишет. Однако пока нам удавалось успешно справляться со всеми вызовами, используя Jenkins.
Как в Jenkins балансируют нагрузку
Для каждого узла указаны:
метки (labels) — описывают задачи, которые могут запускаться на узле; название узла тоже является меткой;
исполнители (executors) — их количество определяет количество одновременно запущенных задач на узле Jenkins.
Все билды встают в очередь, зная label узла, на которой должны выполниться; как только на узле освобождается executor, запускается билд.
С одной стороны, современный CI — это запустить в контейнере что-нибудь очень легковесное (например, pylint, ansible-lint и так далее) и дать обратную связь, а с другой — развернуть окружение, запустить тесты и прибраться за собой. Так что придётся потрудиться даже узлу, на котором работает один лишь управляющий процесс для тестов, а всё остальное развернуто за его пределами. Например, мы запускаем pytest c плагином xdist. Компиляция и сборка тоже могут быть очень ресурсоёмкими. Поэтому нельзя просто взять десяток узлов, прилепить им метку «docker» и насыпать побольше исполнителей: они будут постоянно конкурировать друг с другом за ресурсы. У вас не получится адекватно и просто позаботиться о том, чтобы запущенные пайплайны не задушили друг друга или узел.
Итак, проблема понятна, давайте её решать.
Исходные условия:
Jenkins;
инфраструктура в облаке;
множество команд разработки, потребности которых постоянно растут;
разнообразные требования по мощности к узлам Jenkins;
DevOps умеют писать на Python, но не на Java.
Проблемы
Управление состоянием кластера Jenkins.
Очереди на выполнение. «Слишком большие — дайте мощностей!»
Бережное использование ресурсов. «Очереди нет, надо убрать лишнее».
Когда узлов становится больше 10, становится сложно визуально понять, какие проекты используют выделенные мощности, а какие нет. Нужны мониторинг и аналитика.
Нужно постоянно помнить о необходимости убираться на узлах, чтобы они были в работоспособном состоянии.
Конкуренция на узлах за ресурсы.
Labels hell.
Подход первый: Ansible
Начинали мы с самой простой задачи: нам надо было оперативно добавлять и удалять мощности из кластера. Конечно же, пишем ansible-playbook и сразу наслаждаемся бенефитами.
Набор софта на узле зафиксирован и версионируется.
Чтобы добавить в кластер новый узел, создаём виртуалку с заранее известным SSH-ключом, добавляем в Inventory и запускаем плейбук. Это действие, конечно же, легко автоматизируется, тут кому как будет удобней: Terraform + Ansible и динамический Inventory, а можно и на чистом Ansible. Получаем узел в кластере и данные узла в мониторинге.
Можно пытаться оперативно удалять и добавлять узлы, чтобы сэкономить деньги в ручном режиме. На самом деле это не работает, потому что узлы обычно добавляются там, где мало мощностей, а там, где их хватает, никто не жалуется. Поэтому сподвигнуть вас удалить лишние узлы могут только сбор метрик и аналитика.
Мы периодически дорабатывали плейбук, обычно меняя список пакетов по умолчанию или их версий. Держали всё, что нужно на всех узлах, поддерживали несколько операционных систем и архитектур.
Для балансировки нагрузки мы сделали небольшой пул узлов с меткой «docker», добавили побольше исполнителей и стали там запускать все незатратные процессы. А для проектов и стадий пайплайнов, которые требовали много ресурсов, сделали отдельные узлы с одним исполнителем.
В погоне за оптимизацией эти пулы узлов начинают пересекаться (некоторые узлы имеют более одной метки): выделенные мощные узлы продвигали очередь заданий по проектам, а общие узлы — это способ оптимизации использования ресурсов. Когда проектов всего два-три, всё выглядит просто. Но когда их становится пять, выбирать оптимальное минимальное количество выделенных и общих ресурсов становится сложно, все будет происходить интуитивно и соответственно не контролируемо. И с увеличением количества проектов ваш меточный ад будет всё страшнее. Кажется, что решить эту проблему получится только с помощью снятия метрик пайплайнов и написания алгоритма принятия решений, который будет выдавать нужные значения. И наверняка вам захочется интегрировать это всё в роль и гонять по расписанию.
Спойлер: мы так не сделали.
Да, управление кластером становится простым и лёгким, а ручные манипуляции сводятся к минимуму. Но я, если честно, спустя год начал ненавидеть постоянные добавления и удаления YAML.
С одной стороны, мы приблизились к решению проблем из пунктов 1, 2 и 3. Но я ещё ничего не сказал о мастере. Да и если хочется заботиться о ресурсах как следует, то есть ещё большой резерв по совершенствованию решений проблем из пунктов 2 и 3.
Подход второй: Slave Setup Plugin
В первом приближении стало понятно, как администрировать кластер. Правда, следить за метками было всё ещё неудобно, а идея, которая позволит улучшить ситуацию, выглядела сложной в реализации. Мониторинга и аналитики до сих пор не было. Задачи, которые запускались на docker-узлах, периодически «перерастали» их, это тоже было неприятно.
В следующей итерации мы нашли прекрасный Slave setup plugin, который позволяет выполнять произвольный скрипт при запуске slave-узла и в глобальных настройках на master-узле, чтобы включать slave по мере надобности и выключать его спустя какое-то время бездействия.
Пришло время сэкономить немного денег: будем включать/выключать узлы по требованию. Таким образом можно создать узлы с избытком, чтобы в определённых пределах нагрузки не возникло больших очередей. Переплачивать мы будем только за диски. Хотя и это не обязательно, ведь slave-узел можно не включать/выключать, а создавать/удалять, для master никакой разницы нет.
Тут ничего сложного:
устанавливаем плагин;
переписываем наш Ansible, чтобы он умел распознавать узлы, умеющие выключаться/включаться по требованию;
пишем скрипт, который будет усыплять узел, а потом нежно будить его на работу по первому зову из очереди; вооружаем master этим скриптом.
Тут нужно кое-что пояснить. Для управления кластером Jenkins мы используем собственную платформу Arenadata Cluster Manager (ADCM). Она хранит в себе информацию об узлах и умеет их создавать, включать, отключать и удалять. Наш скрипт запрашивает эти операции, а вся логика содержится в ansible-плейбуках ADCM. В общем случае же достаточно, чтобы скрипт дожидался доступности узлов по SSH после их запуска, а после выключения дожидался, пока облако закончит операцию.
Создавать узлы можно напрямую через rest API Jenkins или выполнять groovy-скрипты на мастере, отправляя тело скрипта так же — через rest api.
Мы пошли вторым путём.
Обновленный кусок плейбука Ansible
- name: Add agent with auto start/stop via jenkins script
tags: [ install, config ]
become: false
when: autostart
delegate_to: localhost
jenkins_script:
user: "{{ services.jenkins.config.jenkins_master_user }}"
password: "{{ services.jenkins.config.jenkins_master_password }}"
validate_certs: False
timeout: 120
url: "{{ services.jenkins.config.jenkins_url }}"
script: |
import hudson.model.*
import jenkins.model.*
import hudson.slaves.*
import hudson.slaves.RetentionStrategy.Demand
import hudson.plugins.sshslaves.*
import hudson.plugins.sshslaves.verifiers.NonVerifyingKeyVerificationStrategy
import org.jenkinsci.plugins.slave_setup.SetupSlaveLauncher
String nodeHostname = "{{ hostvars[inventory_hostname]['ansible_default_ipv4']['address'] }}"
String nodeCredentialID = "{{ services.jenkins.config.jenkins_agent_credentials_id }}"
int nodePort = 22
ComputerLauncher nodeLauncher = new SetupSlaveLauncher(
new SSHLauncher(nodeHostname, // The host to connect to
nodePort, // The port to connect on
nodeCredentialID, // The credentials id to connect as
null, // Options passed to the java vm
null, // Path to the host jdk installation
null, // This will prefix the start slave command
null, // This will suffix the start slave command
30, // Launch timeout in seconds
20, // The number of times to retry connection if the SSH connection is refused
10, // The number of seconds to wait between retries
new NonVerifyingKeyVerificationStrategy()),
"/bin/jenkins_nodes_manager.sh {{ inventory_hostname }} start", // start script
"/bin/jenkins_nodes_manager.sh {{ inventory_hostname }} stop") // stop script
String nodeName = "{{ inventory_hostname }}"
String nodeRemoteFS = "/home/jenkins"
Node node = new DumbSlave(nodeName, nodeRemoteFS, nodeLauncher)
node.setNumExecutors("{{ services.jenkins.config.node_executor_num[inventory_hostname] }}" as Integer)
node.setLabelString("{{ services.jenkins.config.node_labels[inventory_hostname] }}")
node.setRetentionStrategy(new Demand(0, 10))
Jenkins jenkins = Jenkins.get()
jenkins.addNode(node)
- name: Add agent without auto start/stop via jenkins script
tags: [ install, config ]
when: not autostart
become: false
delegate_to: localhost
jenkins_script:
user: "{{ services.jenkins.config.jenkins_master_user }}"
password: "{{ services.jenkins.config.jenkins_master_password }}"
validate_certs: False
timeout: 120
url: "{{ services.jenkins.config.jenkins_url }}"
script: |
import hudson.model.*
import jenkins.model.*
import hudson.slaves.*
import hudson.slaves.RetentionStrategy.Demand
import hudson.plugins.sshslaves.*
import hudson.plugins.sshslaves.verifiers.NonVerifyingKeyVerificationStrategy
import org.jenkinsci.plugins.slave_setup.SetupSlaveLauncher
String nodeHostname = "{{ hostvars[inventory_hostname]['ansible_default_ipv4']['address'] }}"
String nodeCredentialID = "{{ services.jenkins.config.jenkins_agent_credentials_id }}"
int nodePort = 22
ComputerLauncher nodeLauncher = new SSHLauncher(nodeHostname,
nodePort,
nodeCredentialID,
null,
null,
null,
null,
30,
20,
10,
new NonVerifyingKeyVerificationStrategy())
String nodeName = "{{ inventory_hostname }}"
String nodeRemoteFS = "/home/jenkins"
Node node = new DumbSlave(nodeName, nodeRemoteFS, nodeLauncher)
node.setNumExecutors("{{ services.jenkins.config.node_executor_num[inventory_hostname] }}" as Integer)
node.setLabelString("{{ services.jenkins.config.node_labels[inventory_hostname] }}")
node.setRetentionStrategy(new Demand(0, 10))
Jenkins jenkins = Jenkins.get()
jenkins.addNode(node)
Осталю ссылку на репозиторий Cloudbees со всякими скриптами для администрирования Jenkins, там можно черпать вдохновление по написанию скриптов как ансибле выше. Мы добавляем узлы с помощью groovy-скрипта, но у Jenkins много клиентов на различных языках, и вы сможете использовать в Ansible то, что вам ближе.
В итоге нашим разработчикам стало немного легче, а узлов стало гораздо больше. Хотя мы постарались снизить расходы, в работе иногда возникают проблемы, из-за которых затраты могут оказаться выше запланированных.
Например, при обилии узлов некоторые задачи зависали не из-за Jenkins, а из-за исполняемых в них процессов. DevOps не всегда предусматривает таймаут, а заметить зависшую задачу в пользовательском интерфейсе теперь уже сложно, ведь мониторинга метрик пайплайнов у нас ещё не было.
Если вы плохо следите за узлами, то на каких-нибудь из них может кончиться свободное место, Jenkins пометит такие узлы как «нездоровые» и забудет их выключить. Хотелось бы, чтобы он этого не забывал.
Ещё может зависнуть исполнение задачи на очистку. И вместо очистки и усыпления узел продолжит работать, а компания — платить. Да, уборка на jenkins-узлах обычно сводится именно к удалению всего, что осталось после контейнеров и образов.
Очистка jenkins slave узлов
node('jenkins') {
stages = [:]
nodesByLabel(label: 'clean_docker', offline: true).each {
stages[it] = {
node(it) {
stage("Clean ${it}") {
sh 'docker system prune -fa --volumes'
}
}
}
}
parallel(stages)
}
С помощью Slave Setup Plugin мы стали экономнее, но, кажется, с метриками и аналитикой было бы лучше, не были решены и другие проблемы.
Подход третий: динамические узлы
Мы посмотрели на плагин aws и захотели нечто подобное. Динамическое выделение узлов по требованию решает все вышеописанные проблемы, и, как выяснилось, сделать это не так уж и сложно. С freestyle-проектами без знаний Java точно ничего не выйдет; казалось, что и с остальными тоже. Я даже начал забрасывать удочку к нашим Java-разработчикам, а потом нас осенило.
Что сделать, чтобы вас тоже осенило? Мы давно начали использовать pipeline-проекты и их производные: там уже можно делать вид, что ты программист на Groovy, и реализовывать вещи сложнее тех, которыми ограничиваются freestyle-проекты.
А если мы пишем код, то хочется его переиспользовать. Jenkins это умеет: сразу смотрите на shared libraries, хотя это и необязательно. Учить Groovy, скорей всего, будете по документации и по мануалам Jenkins и Stackoverflow. Я провёл за чтением доки и Stackoverflow не один и даже не два часа, а вот документацию Jenkins по shared libraries читал по диагонали, а оказалось, что самый интересный пример был именно там.
Давайте рассмотрим пример с определением собственных DSL-методов; в первую очередь обратите внимание на синтаксис.
If called with a block, the call method will receive a Closure. The type should be defined explicitly to clarify the intent of the step, for example:
// vars/windows.groovy
def call(Closure body) {
node('windows') {
body()
}
}
The Pipeline can then use this variable like any built-in step which accepts a block:
windows {
bat "cmd /?"
}
Пример не исчерпывающий, ведь можно было бы сделать вот так:
// vars/windows.groovy
def call(String goodbye, Closure body) {
node('windows') {
try{
body()
} finally {
echo goodbye
}
}
}
Тогда сам вызов:
windows("I failed") {
bat "cmd /?"
}
Если честно, мне этот синтаксис до сих пор взрывает мозг, я не понимаю, как это работает. То, что closure — это объект и ссылку на него можно передавать в другие функции, вроде понятно, но почему-то из официальной документации эта картинка не образовалась. Один коллега сказал, что это похоже на каррирование. Может, кто-нибудь объяснит в комментариях, как работает этот пример, и поделится ссылкой на документацию языка, а не на документацию Jenkins?
А мы пока двинемся дальше и посмотрим на этот пример:
withJenkinsSlave('Jenkins-slave10', [
'image_id': 'fd8avmufb6l',
'cores': 10,
'memory': 10,
'disk_size': 186,
'disk_type': 'network-ssd-nonreplicated'
]) {
node(DYNAMIC_SLAVE){
stage('Regression tests with Postgres') {
withGHStatus {
def image = ...
image.pull()
timeout(time: 60, activity:true, unit: 'MINUTES') {
image.inside(){
try {
sh '.....'
} catch (e) {
echo “An error occured”
throw e
} finally {
sh 'tar some_test_data'
archiveArtifacts('*.tar.xz')
}
}
}
}
}
}
}
Scripted-pipeline синтаксис с парочкой самописных dsl-методов. WithJenkinsSlave и есть наш метод, который создаст узел, где запустится тело node, а по окончании удалит узел из Jenkins и облака. Наш slave будет иметь 10 ядер, 10 Гб памяти, диск на 186 Гб, а тип диска будет зависеть от облачного провайдера. В области видимости есть переменная DYNAMIC_SLAVE (имя нашего slave-узла), которую мы используем в качестве аргумента для DSL-метода node. Дальше уже более знакомые методы scripted pipeline.
С помощью метода withGHStatus отправим статус на GitHub о начале проверки, поймаем исключения от body, если такое будет, и отправим обратную связь на GitHub.
На всякий случай не забывайте про таймаут, чтобы не зависнуть. Его можно спрятать в withJenkinsSlave со значением по умолчанию.
Что такое jenkins-slave10? Мы будем поднимать, регистрировать и удалять узел, запуская bash. Для нас это единственный недостаток. Нам потребуется постоянный узел с достаточно большим количеством исполнителей, чтобы запускать легковесные скрипты. И такой узел уже есть — jenkins-slave10. На мастере это запускать не стоит. Он почти всегда онлайн, но ничто не мешает его выключать, когда узел простаивает.
В теле withJenkinsSlave у нас используется Bash и Terraform, у последнего есть провайдеры под большинство облаков и гипервизоров, так что подход практически универсален. Мы взяли packer, приготовили образ наших будущих динамических узлов. Манифест для packer не прикладываю, это мало кому будет интересно.
Получившийся образ мы используем в Terrafrom, упакованный в Docker. В образе у нас находится манифест для создания виртуальной машины и два плейбука для добавления и удаления машины в кластере Jenkins. Ansible, который добавляет узел в кластер, тот же самый, что и раньше.
Ну и, наконец, тело самой функции из нашей функции из shared-library:
// vars/withJenkinsSlave.groovy
@Grab(group='org.apache.commons', module='commons-lang3', version='3.12.0')
import org.apache.commons.lang3.RandomStringUtils
def call(String main_node, Map params = [:], Closure body) {
Map localParams = params.clone()
def image = docker.image('terraform-Jenkins-slave:light-x64')
String randomString = org.apache.commons.lang.RandomStringUtils.randomAlphanumeric(5)
def prettyBuildTag = env.BUILD_TAG.toLowerCase().replaceAll('_', '-').replaceAll('Jenkins-', '')
String DYNAMIC_SLAVE = "slave-${randomString.toLowerCase()}-${prettyBuildTag}"
try {
node(main_node) {
cleanWs()
image.pull()
image.inside {
withCredentials([
string(
credentialsId: 'token',
variable: 'TF_VAR_token'),
usernamePassword(
credentialsId: 'Jenkins_user',
passwordVariable: 'Jenkins_API_PASSWORD',
usernameVariable: 'Jenkins_API_USER')
]) {
sh """
cp -r /terraform/* ./
terraform init
terraform apply \
-var \"build_tag=\${BUILD_TAG}\" \
-var 'hostname=${DYNAMIC_SLAVE}' \
-var 'image_id=${localParams.image_id ?: 'fd8avmufb6'}' \
-var 'subnet_id=${localParams.subnet_id ?: 'b0c0f43sk'}' \
-var 'cores=${localParams.cores ?: 32}' \
-var 'memory=${localParams.memory ?: 64}' \
-var 'disk_size=${localParams.disk_size ?: 250}' \
-var 'disk_type=${localParams.disk_type ?: 'network-hdd'}' \
-auto-approve
"""
localParams.ip_address = sh returnStdout: true,
script: 'terraform output -raw vm_ip_address'
sh """
ansible-playbook \
-e \"Jenkins_username=\${Jenkins_API_USER}\" \
-e \"Jenkins_password=\${Jenkins_API_PASSWORD}\" \
-e 'Jenkins_slave_hostname=${DYNAMIC_SLAVE}' \
-e 'Jenkins_slave_ip_address=${localParams.ip_address}' \
-e 'Jenkins_url=http://Jenkins' \
-e 'Jenkins_credential_id=credentials' \
init-Jenkins-slave.yaml
"""
}
}
stash includes: 'terraform.tfstate, terraform.tfstate.backup, .terraform.lock.hcl, .terraform/**',
name: DYNAMIC_SLAVE
}
body()
} finally {
node(main_node) {
try {
cleanWs()
unstash name: DYNAMIC_SLAVE
image.inside {
withCredentials([
string(
credentialsId: 'token',
variable: 'TF_VAR_token'),
usernamePassword(
credentialsId: 'Jenkins_user',
passwordVariable: 'Jenkins_API_PASSWORD',
usernameVariable: 'Jenkins_API_USER')
]) {
sh """
cp -r /terraform/* ./
terraform apply \
-var \"build_tag=\${BUILD_TAG}\" \
-var 'hostname=${DYNAMIC_SLAVE}' \
-var 'image_id=${localParams.image_id ?: 'fd8avmufb6'}' \
-var 'subnet_id=${localParams.subnet_id ?: 'b0c0f43sk'}' \
-var 'cores=${localParams.cores ?: 32}' \
-var 'memory=${localParams.memory ?: 64}' \
-var 'disk_size=${localParams.disk_size ?: 250}' \
-var 'disk_type=${localParams.disk_type ?: 'network-hdd'}' \
-auto-approve -destroy
ansible-playbook \
-e \"Jenkins_username=\${Jenkins_API_USER}\" \
-e \"Jenkins_password=\${Jenkins_API_PASSWORD}\" \
-e 'Jenkins_slave_hostname=${localParams.hostname}' \
-e 'Jenkins_url=http://Jenkins' \
remove-Jenkins-slave.yaml
"""
}
}
} catch (e) {
println('An error occurred when we tried to remove dynamic Jenkins slave')
print e
}
}
}
}
Выбираем случайное название, вызываем Terraform, сохраняем состояние в стэш stash — всё, данные для удаления есть. Регистрируем узел в Jenkins через Ansible, выполняем тело closure body, а потом всё удаляем.
В best practices по Jenkins говорится, что злоупотреблять Groovy не стоит, поскольку этот код исполняется на master. Поэтому в теле функции используются sh, Ansible и Terraform, а не HTTP request plugin. К тому же эти инструменты хорошо перекликаются с задачами, которые обычно решает DevOps, если в его распоряжении есть облако.
В итоге мы имеем ряд следующих достоинств и недостатков.
Достоинства
Ресурсы нужные для выполнения задачи описаны прямо в пайплайне.
Мы получили единую точку входа в узел и можем реализовывать в одном месте все хорошие практики, которые нам покажутся таковыми.
На самом деле, так можно добавлять не только slave-узлы, но и любые окружения, которые нам могут понадобиться, например, сервер Selenoid для наших UI-тестов.
Недостатки
Всё ещё нужен узел, хоть и маломощный. В качестве альтернативы можно использовать Kubernetes-плагин и заменить узел с Docker на Kubernetes-под.
Это решение годится только для типов проекта pipeline и его производных. Нас это в целом устраивает.
Если PR синхронизируется новым пушем, то текущий билд обычно отменяют. Так вот, этот cancel может прилететь прямо в наш terraform destroy или ansible-t remove, что неприятно.
Рекомендую маркировать все создаваемые виртуальные машины меткой из переменной окружения, которая есть в каждой сборке Jenkins — BUILD_TAG из-за недостатка №3. Мы обзавелись ещё одной задачей которая проверяет Jenkins/облако на наличие таких остатков и удаляет их.
Помимо библиотеки Jenkins’а у наших QA есть ещё фреймворк pytest. Мы используем его для формирования тестовых окружений, pytest не всегда убираются из-за ошибки в коде или из-за того, что сборку неожиданно отменили. Поскольку нам известно правило формирования BUILD_TAG, сверившись с Jenkins, мы можем понять, какие виртуальные машины не имеют выполняемых родительских сборок, и их можно легко удалить, почистив облако, Jenkins и так далее.
Итого, чего нам удалось добиться:
Мы настраиваем кластер и управляем им через Ansible. Но, поскольку о master’е речи не шло, только о slave-узлах, вопрос решён лишь наполовину или меньше. Жизненный цикл master’а надо покрывать тестами; недостаточно написать плейбук, который будет обновлять master’а в надежде, что ничего не взорвётся. То есть задача весьма нетривиальная.
Что же касается Jenkins job, то их мы храним в виде YAML и накатываем с помощью Jenkins job builder. Утилита конвертирует YAML в XML описание job и грузит их через rest api, написана на Python, поэтому дорабатывать её мы можем самостоятельно, если на это есть необходимость.
Очередей в Jenkins больше нет, как и простаиващих ресурсов.
Получили единую точку входа для всех стадий пайплайнов в виде библиотеки, что позволяет нам в одном месте реализовывать все наши лучшие практики, но появилась задача по CI для самой библиотеки, которую мы пока не решали.
Нет задачи по поддержанию узлов в рабочем состоянии, но есть задача по приборке мусора. Из Jenkins мы получаем данные о запущенных сборках и всегда можем пойти и подчистить в облаке виртуалки, которые остались по каким либо причинам, например cancel самого билда о котором мы упоминули ранее.
Стейджи изолированы виртуалками, никакой конкуренции.
Нет меток — нет проблем.
Мы используем такой же подход на kvm-гипервизоре архитектуры PowerPC. Но там ресурсы ограничены: как ожидать готовности гипервизора обслуживать наши запросы нам еще предстоит исследовать, пока мы еще не упирались в его максимальный ресурс.
Итого, мы полностью решили задачи 2, 3, 5, 6 и 7, а над задачами 1 и 4 ещё предстоит поработать для достижения идеала. Из смешного то, что никакой балансировки в общем то и нет, все по требованию здесь и сейчас. Если сравнивать например с GitHub Actions, то есть подозрение, что так сделать не получится, те решения которые я видел в Open Source основывались на анализе очереди и добавлении/удалении раннеров.
В заключение хочу поблагодарить своего коллегу Дмитрия К. за проверку гипотез и исходный код.