В 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.

Проблемы

  1. Управление состоянием кластера Jenkins.

  2. Очереди на выполнение. «Слишком большие — дайте мощностей!»

  3. Бережное использование ресурсов. «Очереди нет, надо убрать лишнее».

  4. Когда узлов становится больше 10, становится сложно визуально понять, какие проекты используют выделенные мощности, а какие нет. Нужны мониторинг и аналитика.

  5. Нужно постоянно помнить о необходимости убираться на узлах, чтобы они были в работоспособном состоянии.

  6. Конкуренция на узлах за ресурсы.

  7. 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, если в его распоряжении есть облако. 

В итоге мы имеем ряд следующих достоинств и недостатков.

Достоинства

  1. Ресурсы нужные для выполнения задачи описаны прямо в пайплайне.

  2. Мы получили единую точку входа в узел и можем реализовывать в одном месте все хорошие практики, которые нам покажутся таковыми.

  3. На самом деле, так можно добавлять не только slave-узлы, но и любые окружения, которые нам могут понадобиться, например, сервер Selenoid для наших UI-тестов.

Недостатки

  1. Всё ещё нужен узел, хоть и маломощный. В качестве альтернативы можно использовать Kubernetes-плагин и заменить узел с Docker на Kubernetes-под.

  2. Это решение годится только для типов проекта pipeline и его производных. Нас это в целом устраивает.

  3. Если PR синхронизируется новым пушем, то текущий билд обычно отменяют. Так вот, этот cancel может прилететь прямо в наш terraform destroy или ansible-t remove, что неприятно. 

Рекомендую маркировать все создаваемые виртуальные машины меткой из переменной окружения, которая есть в каждой сборке Jenkins — BUILD_TAG из-за недостатка №3. Мы обзавелись ещё одной задачей которая проверяет Jenkins/облако на наличие таких остатков и удаляет их.  

Помимо библиотеки Jenkins’а у наших QA есть ещё фреймворк pytest. Мы используем его для формирования тестовых окружений, pytest не всегда убираются из-за ошибки в коде или из-за того, что сборку неожиданно отменили. Поскольку нам известно правило формирования BUILD_TAG, сверившись с Jenkins, мы можем понять, какие виртуальные машины не имеют выполняемых родительских сборок, и их можно легко удалить, почистив облако, Jenkins и так далее.

Итого, чего нам удалось добиться:

  1. Мы настраиваем кластер и управляем им через Ansible. Но, поскольку о master’е речи не шло, только о slave-узлах, вопрос решён лишь наполовину или меньше. Жизненный цикл master’а надо покрывать тестами; недостаточно написать плейбук, который будет обновлять master’а в надежде, что ничего не взорвётся. То есть задача весьма нетривиальная. 

    Что же касается Jenkins job, то их мы храним в виде YAML и накатываем с помощью Jenkins job builder. Утилита конвертирует YAML в XML описание job и грузит их через rest api, написана на Python, поэтому дорабатывать её мы можем самостоятельно, если на это есть необходимость.

  2. Очередей в Jenkins больше нет, как и простаиващих ресурсов.

  3. Получили единую точку входа для всех стадий пайплайнов в виде библиотеки, что позволяет нам в одном месте реализовывать все наши лучшие практики, но появилась задача по CI для самой библиотеки, которую мы пока не решали.

  4. Нет задачи по поддержанию узлов в рабочем состоянии, но есть задача по приборке мусора. Из Jenkins мы получаем данные о запущенных сборках и всегда можем пойти и подчистить в облаке виртуалки, которые остались по каким либо причинам, например cancel самого билда о котором мы упоминули ранее.

  5. Стейджи изолированы виртуалками, никакой конкуренции.

  6. Нет меток — нет проблем.

  7. Мы используем такой же подход на kvm-гипервизоре архитектуры PowerPC. Но там ресурсы ограничены: как ожидать готовности гипервизора обслуживать наши запросы нам еще предстоит исследовать, пока мы еще не упирались в его максимальный ресурс.

Итого, мы полностью решили задачи 2, 3, 5, 6 и 7, а над задачами 1 и 4 ещё предстоит поработать для достижения идеала. Из смешного то, что никакой балансировки в общем то и нет, все по требованию здесь и сейчас. Если сравнивать например с GitHub Actions, то есть подозрение, что так сделать не получится, те решения которые я видел в Open Source основывались на анализе очереди и добавлении/удалении раннеров.

В заключение хочу поблагодарить своего коллегу Дмитрия К. за проверку гипотез и исходный код.

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


  1. identw
    09.11.2021 16:15

    Если честно, мне этот синтаксис до сих пор взрывает мозг

    Да вроде норм, в других языках похожие вещи есть.

    В ruby например примерно такое-же называется блоками:

    def example
    yield 101
    end

    example do |i|
    # any code
    # i будет равен 101
    puts i * 10
    end

    example {|i| puts i * 100}


    Результат:
    1010
    10100

    P.S. не могу понять как нормально оформить код в новом редакторе хабра, сори =(


    1. Maunty Автор
      10.11.2021 09:41

      Я ruby не знаю и даже наверно не сожалею об этом, судя по примеру кода :)

      Когда то давно меня в университете учили С, и пример

      function (var1, var2, var3){
      	somebody
      }

      больше похож на объявление функции, чем на её вызов.

      В groovy можно вызывать функции без () и такой пример кстати будет меньше вызывать вопросов.

      function var1: value1, var2: value2, body: { my_clousure_body }

      Update:
      Пока искал ссылку на опускание круглых скобок в документации groovy нашел и наконец то одну строку про такой синтаксис и в документации наконец то. https://groovy-lang.org/style-guide.html в 6-ом пункте Omitting parentheses.


  1. daduskacpokus
    10.11.2021 09:41

    прям стесняюсь спросить, а зачем весь этот секс с добавлением слейвов или вы про jenkins helm chart не слышали и cluster-autoscaler pipeline{agent{any{}}} ?


    1. Maunty Автор
      10.11.2021 11:10
      +1

      Нет, не слышали. Это всё в k8s, мы в этом направлении только делаем свои первые шаги. Исторически наша экосистема развивалась без него. И вот только несколько причин этого:

      • Наши продукты мало подходят под запуск в кубере, не все, но в большинстве своём.

      • У нас есть есть свои плагины для pytest основывающиеся на докере, сами понимаете эспертиза написать что то на докере << запустить все нужные сущности в k8s и потом прибить их. На докере у нас работает все очень быстро. И разработчик может локально тесты запустить без k8s или аналога у себя на машине. Хотя задача использовать в процессе кубер чтобы размазать нагрузку по кластеру, а не на одной ноде jenkins, что в свою очередь позволит добавить параллелизма на CI и сократить время выполнения есть и это ближайшие перспективы.

      • У нас пока что нет прода который можно было бы засунуть в кубер и для нас это решило бы какие то проблемы.

      • Засунуть все в куберо не панацея, ноды кубера нужно туда добавлять, хотя кубер как сервис в облаках с этим должен справляться, но что делать если под перерос вашу самую жирную ноду ? Что делать понятно, в общем-то :) В любом случае это не серебрянная пуля, где мозгов не надо, просто спускаешь курок и сразу пожинаешь плоды.

      Kубер потребует эволюция экосистемы. Мы еще молодая компания и без кубера стартуется гораздо продуктивнее чем с ним. А уж про найм специалистов которые научились писать ямл для кубера и просят зп выше 300, но на вопросы про ЯП начинают говорить, что для этого есть разработчки, а код писать это не работа девопса, я вообще промолчу.

      Спасибо за ссылки, добавим в roadmap команды посмотреть на них.


      1. daduskacpokus
        10.11.2021 11:29

        вы пишите "мы работаем в облаке" - kubernetes это самый что ни на есть cloud native, странно, что вместо него вы поднимаете какие-то виртуалки и тратите кучу сил чтобы изобрести свой собственный велосипед


        1. Maunty Автор
          10.11.2021 11:45

          Тоесть 4 пунтов почему не kubernetes вам не хватило? Сам kubernetes cloud native замечательно, что это меняет ? Нашей нагрузке и процессам лучше на виртуалках чем на подах, поэтому виртуалки.

          Статья про идею, а не про велосипед, хотите облако, велком, хотите свои гипервизоры без облака (например как у нас получилось с libvirtd на powerpc), тоже пожалуйcта, прочитайте, используйте, cэкономите время, там правда еще прийдется подумать над парочкой проблем - но все решаемо.


          1. daduskacpokus
            10.11.2021 13:28

            В четвёртых, почитайте про HPA и для чего он нужен

            Во-вторых, в чём проблема разрабатывать так как привык devteam в своём докер-компосе, а тестить и релизиться в хелм чартах? helm create слишком сложен для вас?

            Во-первых и в третьих, ваш проект менеджер скорее всего просто соглашается с теми решениями которые вы ему продаёте

            PS: справедливости ради сказать, ваш скил в Groovy достоин лучшего применения


            1. Maunty Автор
              10.11.2021 15:03
              +1

              Во-первых и в третьих, ваш проект менеджер скорее всего просто соглашается с теми решениями которые вы ему продаёте.

              Это точно? откуда вы такие выводы сделали? @Sunchezzz я с Антоном оказывается обманываю тебя все это время. Надо hadoop с greenplam в кубер засовывать. Это решает все проблемы.

              В четвёртых, почитайте про HPA и для чего он нужен

              Я вам про Фому, вы мне про Ерему. Кубер молодец, разве я с этим спорил? Главное чтобы и все остальные вокруг были достаточно умны чтобы с этим дальше жить. Я году в 18-ом когда еще ходил на конференции послушать умных людей, слышал только про то, как люди пилили монолиты, мигрировали в k8s, кололись и продолжали есть кактус. Про гонки за ресурсы ядра, утилиты из разряда ebpf, чтобы дебажить эти проблемы. До сих пор по ночам просыпаюсь в холодном поту когда вспоминаю это, если для вас это детский лепет, я опладирую вам стоя - скиньте ссылку на своё резюме :)

              Во-вторых, в чём проблема разрабатывать так как привык devteam в своём докер-компосе, а тестить и релизиться в хелм чартах? helm create слишком сложен для вас?

              Причем тут heml create с разницой запустить все на докер демоне или все пихнуть в комбайм под названием k8s ? На докере проще закодить, автоматизировать, проще девопс, проще для qa, проще для всех кто с этим кодом работает и поддерживает. А в прочем https://github.com/arenadata/adcm_pytest_plugin welcome, всё открыто, можете запилить нам новый pull request с драйвером на k8s вместо докера.

              В k8s мы прийдем, но надо идти от простого к сложному.


    1. Maunty Автор
      10.11.2021 12:27

      Я бегло посмотрел,

      This chart installs a Jenkins server which spawns agents on Kubernetes utilizing the Jenkins Kubernetes plugin.

      Так у нас в статье тоже есть ссылка на этот плагин, наверно я не достаточно расписал про него. Мы его так же используем и запускаем нагрузку которая живет в поде нормально именно в небольшом кластере k8s.

      Правильно я понимаю что если если jenkins master переместить в k8s тогда достаточно pipeline{agent{any{}}} вместо podTemplate { node(POD_LABEL) {stage('Run shell') {sh 'echo hello world'}}} ? Немного проще, но если нужен не дефолтный pod то скорей всего any{} не подойдет и надо будет объявлять кастомный темплейт в конфиге для heml, по сути просто перенос описания из одного места в другое.

      В общем то да, если есть подходящая нагрузка то этот плагин делает тоже самое, что и "весь это секс" но на виртуалках.

      Опять же, https://plugins.jenkins.io/kubernetes/ написан на java, умеете java, написать такой же плагин под compute engine облака которое вам надо не составит труда. Умеете java вообще не читайте эту статью :) Она не для вас :)


      1. daduskacpokus
        10.11.2021 13:32
        +1

        pipeline {
          agent {
              kubernetes {
              yaml """
                apiVersion: v1
                kind: Pod
                spec:
                  nodeSelector:
                    node.kubernetes.io/lifecycle: spot

        пож-ста запускайте ваш агент по нодлейбл селекторам