Как стать автором
Обновить
38.31
СВД ВС
Надежность. Безопасность. Реальное время

Jenkins: оптимизируя динамический пайплайн → распределённая сборка компонентов ОС

Уровень сложностиСредний
Время на прочтение12 мин
Количество просмотров3.8K


В процессе улучшения подходов к менеджменту зависимостей компонентов нашей Операционной Системы появилась необходимость перейти от монолитной статической сборочной системы на основе CI/CD инструментов к динамическому распределённому подходу с порождением сотен и тысяч автономных задач. Как выяснилось в процессе, это не самый радужный сценарий использования систем автоматизации, но вполне достижимый.


В результате был спроектирован и внедрён динамический сборочный конвейер на базе Jenkins, масштабируемый как горизонтально, так и вертикально. В статье расскажем как он устроен, решение каких проблем потребовало адресной оптимизации по скорости выполнения, и какие подводные камни повсплывали.


Также частично раскроем информацию о том, как мы выполняем распределённую сборку дистрибутивов.


Ожидается много текста и примеров кода.


Введение: отправная точка


К моменту постановки задачи основной сборочный конвейер дистрибутива опирался на Jenkins в качестве среды автоматизации, Docker с развернутыми средствами разработки для воспроизводства эталонного сборочного окружения и Gitea в роли системы управления исходным кодом. Кроме того, использовался ряд дополнительных инструментов для тестирования, управления дефектами, project-management и пр.


Основным камнем преткновения являлась централизованная иерархия ключевых репозиториев, составляющих дистрибутив, выполненная по принципу мета-репов с подключением сабмодулей. Если на уровне подсистем и отдельных команд разработчиков такой подход ещё выдерживает критику, то на этапе объединения в единое окружение все телодвижения команд упираются в необходимость предложения правок в мета-репозитории мейнтейнеров. С одной стороны, это существенно ограничивает свободу действий мейнтейнеров при работе над собственными инфраструктурными прожектами. С другой, никак не связанные с ними разработчики также должны отслеживать чужую кухню и блокировать свои работы до апрува правок в сборочные репы. Автоматизации эта проблема подлежит весьма условно и время от времени дает сбои. Основная цель в этом случае — разграничение объектов контроля и минимизация дедлоков/простоев.


Стоит ли упоминать, что и контроль зависимостей в таком ключе является неоправданно сложным квестом?


Вторая проблема, частично являющаяся следствием первой — невозможность полноценного распараллеливания сборки по сборочным серверам. Монолитность иерархии приводит как к необходимости ручного распределения (хоть и однократно, но в крайне ограниченном объеме) сборочных заданий по имеющимся серверам, так и невозможности в произвольный момент времени радикально нарастить перечень серверов.


Вертикальное масштабирование - необходимый или достаточный фактор?

За годы, и уже десятилетия борьбы, в том числе и с этими проблемами, мы неоднократно проходили стадию


Щас купим новый трушный сервер и тогда заживём

Новый сервер можно и нужно покупать, но это не отменяет перманентной потребности в расширении вычислительных мощностей. Регулярно появляются новые задачи и необходимость выполнения отдельных сборок с уникальными параметрами. Навскидку:


  • Инженерные nightly билды
  • Релизные билды с учетом специфичных требований различных систем сертификации
  • Контрольные дебаг-билды и билды с code-coverage
  • Отдельные контрольные билды в рамках концепции безопасной разработки
  • Поддержка нескольких дистрибутивов с разными конфигурациями и адресным применением вышеперечисленных опций

В условиях наличия обширного пласта регулярно простаивающих высокопроизводительных машин разработчиков, вертикальное масштабирование сборочных мощностей в ущерб горизонтальному не выглядит как достаточное условие, хотя и необходимое. А во время подготовки и стабилизации релиза «жаба» начинает капитально душить, и сборки, в идеале, хотелось бы видеть каждый час и даже быстрее.


Нельзя сказать, что действовавший сборочный пайплайн был плох. За последние 6 мажорных релизов осечек он не давал. Но с какого-то момента накопленные требования и общее повышение зрелости процессов разработки привели к утрате целесообразности дальнейшего вложения ресурсов в поддержку «status quo».


Построение графа зависимостей


Первым шагом отказались от мета-репозиториев мейнтейнеров. В каждый сборочный репозиторий, представляющий самостоятельный объект сборки, был добавлен дескриптор для используемой пакетной системы. Это позволяет командам хранить свои игрушки прямо в собственных репах и решать все свои вопросы в пределах атомарного PR без привлечения внешних для команды специалистов.


Для разработчиков задача существенно упростилась. А вот мейнтейнеры лишились основного инструмента. На замену пришлось запилить отдельный конвейер, выполняющий построение сборочной иерархии в виде дерева зависимостей.


Начали со скрипта обхода репозиториев через HTTP API. Он выполняет обход всех доступных пользователю (от имени которого выполняется скрипт с правами RO) репозиториев на сервере и производит извлечение дескрипторов. Пример кода получения результатов выполнения поддерживаемых запросов под спойлером:


Выполнение запросов к Gitea API
import json
import requests
from requests.adapters import HTTPAdapter, Retry

def http_api_request( url, max_pages, params = '' ):
    jsons   = []
    token   = {'content-type':  'application/json',
               'accept':        'application/json',
               'Authorization': 'token xxxxxxxxxxxxxxxxxxxxxxxxx...'}
    session = requests.Session()
    retries = Retry( total = 5, backoff_factor = 0.5,
                     status_forcelist = [500, 502, 503, 504] )
    session.mount( 'http://', HTTPAdapter( max_retries=retries ) )
    try:
        page = 0
        while ( True ):
            reply = session.get( url + '?limit=100&page=' +
                                 str( page + 1 ) + params,
                                 headers = token )
            json_ = reply.json()
            if (json_ == None) or (len( json_ ) == 0):
                break
            else:
                jsons.append( json_ )
                if (max_pages != 0) and (page + 1 >= max_pages):
                    break
            page += 1
    except Exception as e:
        print( 'Error: failed on "' + url + '" (message: "' +
               str( e ) + '")' )
        exit( 1 )
    return jsons

Типичные дескрипторы обычно содержат ссылку на скачиваемый репозиторий и соответствующий коммит. Таким образом, завершённый набор файлов-дескрипторов полностью определяет билд дистрибутива. Это достаточно удобно для хранения вместо набора бинарей или архивов, особенно для промежуточных билдов.


Для построения и анализа самого графа использовали networkx. Выбор обусловлен поддержкой формата .dot, в котором хранит графы проект Graphviz, используемый в дальнейшем для визуализации.


Поиск некоторых аномалий в зависимостях:


  • Отсутствие циклов на графе. Наличие хотя бы одного цикла в нашем случае является ошибкой:

import networkx as nx
...
graph = nx.DiGraph( nx.nx_pydot.read_dot( fname ) )
cycles = nx.simple_cycles( graph )
cycle  = ''
for nodes in cycles:
    for node in nodes:
        cycle += '"' + node + '" → '
    cycle += '"' + nodes[0] + '"'
    break
return cycle

  • Поиск изолированных вершин. Наличие вершин без пред-/пост- зависимостей в нашем случае является ошибкой:

nodes = ''
for node in graph.nodes():
    if graph.degree( node ) == 0:
        nodes += node
return nodes

Скрипт контролирует и другие аномалии, связанные уже с особенностями пакетного менеджера. Оформлено всё это «хозяйство» в виде автономной задачи в Jenkins, чтобы разработчики в любой команде могли убедиться, что их репозитории включены в дистрибутив непротиворечивым образом.


Результирующий граф «нарезается по слоям», что определяет очерёдность сборки. Множество дескрипторов некоторого «слоя» мощностью M, совместно с тиражированием каждого дескриптора по N поддерживаемым процессорным архитектурам, составляет полный объем потенциально распараллеливаемых сборочных задач M x N на слой. Как разработчикам, так и мейнтейнерам предоставляется сгенерированная тут же простейшая «веб-морда», чтобы визуально посмотреть что они включили в билд, на каком этапе собирается интересующий компонент, правильно ли расставлены зависимости и т.п. Кроме того, формируется слепок вторичных дескрипторов (по архитектурам) и файл с их распределением по слоям для последующей стадии конвейера — сборки.


Тестовый набор пакетов для собственных экспериментов мейнтейнеров на раннем этапе внедрения распределённой системы сборки (каждый дескриптор может продуцировать несколько пакетов, например, *-dev и *-runtime):


Визуализация дерева зависимостей


На всё про всё, с учетом обхода 500+ репов в один поток, выполнения анализа и генерации выходных материалов, уходит чуть меньше двух минут, поскольку ещё на берегу сформировали договоренности о точном именовании дескрипторов и их расположении в репозиториях. Так при обходе не нужно парсить все иерархии исходников.


С визуализацией графа капитально помогает Graphviz, поскольку умеет рисовать в SVG. Ну а подпатчить его руками и добавить ссылки — дело не хитрое >}.


Поиск сделали по-простому — распарсили в отдельный .js контент страниц с описанием пакетов и подгрузили его на все страницы:


Описание пакета и форма поиска


Масштабирование серверов


Сразу отмечу, что автор не является разработчиком на Java или DevOps-инженером. На всё это «хозяйство» пришлось смотреть с опытом системного программиста. Какие-то замечания, безусловно, будут обоснованы, как и упрёки в невладении сакральным знанием. Однако, анализ ряда архитектурных решений Jenkins, а также производных от них ограничений, и сейчас, постфактум, вызывает выгорание окружающего пространства в радиусе нескольких парсеков. Данный нюанс не отменяет того обстоятельства, что цель в итоге успешно достигнута.


Только теперь мы подобрались к Jenkins и построению динамического конвейера. Под спойлером термины в том виде, как мы их трактуем для себя; они могут упростить понимание изложенного далее:


Jenkins и его сущности
  • pipeline — множество взаимосвязанных задач, направленных на решение общей цели
  • job — отдельная задача, исполняемая Jenkins на отдельном узле (сервере) и в отдельном контейнере
  • build — запущенный на исполнение job, обычно обладающий артефактами по результатам работы (в статье явно не используется, билдом называется сборка дистрибутива ОС)
  • build ID (BID) — уникальный целочисленный идентификатор билда
  • stage — часть build, характеризующая некоторую последовательность действий на языке, производном от Groovy (т.к. имеет свои особенности и ограничения)
  • step — отдельный шаг в stage, часто связанный с вызовом конкретного плагина Jenkins (примеры: writeFile, readFile)
  • CPS — одна из причин, почему в Jenkins нельзя использовать нормальный Groovy
  • slot — максимальное число одновременно исполняемых job / build на сервере (настраивается администратором Jenkins для каждого сервера)

Пример pipeline, который может на практике оказаться как статическим, так и динамическим:


Пример pipeline


Все имеющиеся сервера были занесены в библиотеку профилей и отранжированы по уменьшению производительности:


  • число ядер и потоков CPU для определения предельного числа параллельно исполняемых сборочных потоков на задачу
  • число slot-ов для определения максимального числа параллельно запускаемых сборочных задач
  • интегральный коэффициент производительности (для расстановки приоритетов серверам: чем больше коэффициент, тем больше вероятность назначения ему задачи)

Перед порождением вороха сборочных задач определяется доступность серверов. Сделать это можно двумя способами:


  1. Спросить у Jenkins:

if ( Jenkins.getInstance().getNode( "<сервер>" ).toComputer().isOnline() ) {
        ...
} else {
        ...
}

  1. Попытаться запустить тестовый job на всех серверах разом и, если посчастливится, даже разбудить кого-то из них:

def jobs     = [:]
def statuses = [:]
for ( server in servers ) {
    def name            = server.name
    statuses["${name}"] = ''
    jobs[server.name]   = {
        try {
            timeout( time: <X>, unit: 'MINUTES' ) {
                def job = build job: '<job-name>',
                                parameters: [...],
                                propagate: false,
                                wait: true
                catchError( buildResult: 'SUCCESS', stageResult: 'FAILURE' ) {
                    if ( (job.result != "SUCCESS") && (job.result != "UNSTABLE") )
                        statuses["${name}"] = 'FAILURE'
                }
                if ( job.result == "SUCCESS" ) {
                    statuses["${name}"] = 'SUCCESS'
                    /* Здесь можно скопировать из job-а артефакты, определив,
                     * например, свободное пространство на SSD/HDD */
                    copyArtifacts( ... );
                }
            }
        } catch ( ... ) {
            /* Чтобы отличить таймаут от прерывания задачи придется заморочиться:
             * stackoverflow.com/questions/51260440 */
        }
    }
}
parallel jobs

Первый вариант проще, но не позволяет выводить сервера из suspend-а. В тоже время, без таймаута во втором варианте, job, запущенный на отсутствующем сервере, будет висеть вечно и толку от такого пайплайна не будет. Но сам перехват таймаута достаточно некрасивый — см. ссылку в коде. Также придется потратить время на настройку таймаутов засыпания/пробуждения серверов (в Jenkins) и таймаута ожидания job-а в своем коде. На практике эти параметры оказались достаточно недетерминированными, пришлось их брать с хорошим запасом.


Горизонтальное масштабирование в таком варианте достигается простым подключением новой машины к Jenkins и добавлением её профиля в код (всё это легко выносится в JenkinsLib). Отключили сервер на обслуживание? Скатертью ему дорога, билд дистрибутива не завалится.


Вертикальное масштабирование осуществляется как обычно — обновлением комплектации текущих серверов.


Оптимизация динамического пайплайна


В последнем примере кода приводится один из не самых частых вариантов динамического запуска кода в параллель, причем, не через тиражирование stage-ей или step-ов. Распараллеливанию здесь подлежат именно job-ы, что позволяет контролировать общее их количество и сопоставлять с доступными слотами и производительностью серверов. По классике это сделать сложнее, т.к. в модели вычислений Jenkins статусы каждой отдельной операции или stage формализованы никак плохо.


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


На словах выглядит неплохо, но первые же эксперименты привели к ощущению, что Jenkins как-то подозрительно сильно тупит даже на самом порождении задач. Синтетическая генерация ничего не выполняющих тестовых job-ов в количестве 142 штуки длится на пустом месте 15 минут. Поскольку пиковое число таких задач ожидалось в районе нескольких тысяч, тратить эти часы на пустом месте откровенно не хотелось. К тому же, эффект практически не зависел от производительности и числа вовлечённых серверов, что потенциально минимизировало любой эффект от распределённой сборки.


Был написан простейший тест, выполняющий создание сотни мелких файлов:


Тест файловых операций
echo "Запись 100 файлов"
for ( int i = 0; i < 100; i++ )
    writeFile file: 'test-' + i.toString(), text: 'empty' + i.toString()
echo "Чтение 100 файлов"
for ( int i = 0; i < 100; i++ )
    if ( fileExists( 'test-' + i.toString() ) ) {
        try {
            def readContent = readFile 'test-' + i.toString()
        } catch (Exception e) {
            echo "Error: " + 'test-' + i.toString()
        }
    } else
        echo "Файл не найден: " + 'test-' + i.toString()

echo "Shell тест"
sh '''
    for ((i = 0 ; i < 100; i++)); do
        echo 'empty' > shell-file-${i}
    done
    for ((i = 0 ; i < 100; i++)); do
        if [ -f shell-file-${i} ]; then
            VAR=`cat shell-file-${i}`
        fi
    done
'''
echo "Shell тест завершён"

Результаты знатно впечатлили. Shell-реализация работает менее секунды, Groovy-реализация от 35 до 45 на разных серверах. Из 15 минут выше около 6 занимала именно запись различных логов. Переделывание на Shell откровенно дурно пахло и, как выяснилось позднее, не было способно решить проблему в принципе.


Проблема оказалась комплексной. By design, один из режимов работы Jenkins подразумевает повышение живучести инстансов сервиса за счет параноидального кэширования всего и вся на хард. В нашем случае это явно было избыточным — "померла, значит померла", лучше перезапустим сборку билда, чем будем 24x7 ловить тормоза. При отключении durabilityHint( 'PERFORMANCE_OPTIMIZED' ) тест стал показывать сопоставимое время, не демонстрируя зависимость от реализации. Время синтетического билда упало до 6.25 мин.


Уже сильно лучше, но всё равно выглядит плохо. В процессе раскуривания логов и подключения на горячую к контейнерам Jenkins обнаружили, что на одном из серверов только что запущенный докер почти минуту что-то делает с загрузкой одного ядра CPU. Поскольку каждый job запускается в параллель на разных серверах, но абсолютно синхронно (см. дальше про асинхронность), а сборка компонентов дистрибутива осуществляется по «слоям», это неизбежно приведёт к повторению таких сценариев снова и снова. На этом сервере нам не фортануло с fakeroot has become extremely slow inside docker. Полечили опциями запуска docker, время упало уже до 4.1 мин.


Далее выяснилось, что квест с медленностью отдельных step-операций до конца так и не закончился. Из-за вынесения большей части утилитарного кода в разделяемую библиотеку и унификации интерфейсов к методам библиотечного класса, сложилась ситуация как в комбинированном тесте по мотивам предшествующего:


Комбинированный тест файловых операций
echo "start"
for ( int i = 0; i < 100; i++ )
    sh """echo 'empty' > complex-file-${i}"""
for ( int i = 0; i < 100; i++ )
    def stdout = sh( script: """if [ -f complex-file-${i} ]; then
                                    cat complex-file-${i}
                                fi""", returnStdout: true )
echo "stop"

По неизвестной до сих пор причине этот код медленнее любой из предшествующих реализаций, причем в несколько раз. Без отключения maximum durability mode он работает около полутора минут, с отключением все равно более 15 секунд. Мультипликативный эффект суммарно ожидался уже в пределах десятков минут, что также крайне абыдно! Приговорили многократный запуск sh-step, выделили весь его код в отдельный метод. Результат распределения стал укладываться в 2.7 мин. Дальше сам Jenkins решили не мучить, поскольку эффект ожидался минимальным, да и оставались вопросы с более очевидным профитом от оптимизации — балансировка нагрузки и асинхронность.


Как уже было сказано выше, код, с помощью которого мы будим сервера, лёг в основу генератора сборочных job-ов. Действуя по принципу


def jobs = [:]
for ( ... ) {
    jobs[key] = {
        ...
    }
}
parallel jobs

и имея M x N задач для распараллеливания, стали ставить эксперименты по группировке дескрипторов:


  • можно собирать все архитектуры одного пакета группой в одной задаче
  • или не группировать вовсе и максимизировать вовлечение всех серверов

После включения сборки компонентов во втором случае на тестовом наборе позитивный эффект варьируется от 15% до 20%.


Другим фактором балансировки нагрузки стало выделение «тяжелых» задач и запуск их на выделенных серверах, независимо от числа доступных слотов и, отдавая предпочтение наиболее производительным из них. А обход «тяжелых» задач перед остальными вообще дает им эксклюзивный доступ к вычислительным мощностям: ровно одна архитектура/компонент на сервер. В тестовом наборе такие сценарии не затрагивались, поскольку ресурсоемкие задачи отсутствовали как класс. В боевом исполнении эффект уже измерялся часами, поскольку именно тяжелые задачи изначально и были основной трудоемкостью.


Внимательный специалист в последнем фрагменте кода заметит огромную дыру — он абсолютно синхронен. Допустим, что на X — 1 сервере запущена сборка такого же числа обычных задач, а на последнем ровно одна «тяжелая». Это приведёт к тому, что подавляющее большинство серверов будет большую часть времени простаивать.


Много времени потратили на борьбу с CPS и его паталогической нелюбовью к closures, перепробовали следующие варианты:


  • Перво-наперво, попробовали смастерить привычный на системном уровне пул-потоков. Тут же нагуглился POSIX-подобный класс Thread и даже с почти классическим методом join(), правда без аналога pthread_timedjoin*(). В Jenkins банальные потоки просто не заработали. Как можно было сломать базовую многопоточность я понять отказываюсь, даже учитывая особенности сервиса. Fail!
  • У используемого step-плагина имеется намёк на асинхронность в виде waitForBuild(). Ему честно передается BID, но вот сам build job ... при таком сценарии перестаёт возвращать ID честно запущенного асинхронного job-а. Частичный Fail, т.к. job все-таки стал асинхронным!
  • Искали путь запуска в неблокирующем режиме конструкции parallel. Разработчики Jenkins вообще в эту сторону не думали. И снова Fail!

В итоге и эту проблему преодолели, но иначе. У самого Jenkins с асинхронностью совсем дела плохи.


Резюме


Надеемся, что статья оказалась занимательной и наш опыт будет кому-то полезен. Если данная тематика, в целом, окажется читателям интересной, можем в будущем рассказать, как организовали отсутствующую у Jenkins асинхронность для этого динамического пайплайна и каков общий достигнутый бонус.


Подписывайтесь на наш канал, чтобы быть в курсе свежих новостей

Теги:
Хабы:
Всего голосов 8: ↑7 и ↓1+6
Комментарии11

Публикации

Информация

Сайт
www.kpda.ru
Дата регистрации
Дата основания
Численность
51–100 человек
Местоположение
Россия
Представитель
Игорь Дерябин