03 нояб. 2025 г.·7 мин чтения

Узкие места очереди CI-раннеров: найдите ожидание до того, как оно начнёт мешать

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

Узкие места очереди CI-раннеров: найдите ожидание до того, как оно начнёт мешать

Почему очередь мешает ещё до того, как падают сборки

Большинство команд чувствуют боль CI ещё до того, как что-то действительно ломается. Задача стоит в очереди четыре или пять минут, потом выполняется шесть, и никто не называет это простоем. Но это ожидание накапливается в течение всего дня и постепенно съедает внимание.

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

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

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

Простой пример быстро это показывает. Допустим, растущая команда открывает 30 pull request за один загруженный день. Если каждая задача ждёт по три минуты до старта, это 90 минут потерянного времени команды ещё до начала работы.

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

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

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

Что измерять в первую очередь

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

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

Также полезно помечать каждую задачу небольшим набором классов, чтобы сравнивать похожую работу. Обычно достаточно пяти меток: build, unit test, integration test, deploy и housekeeping. Держите их простыми и одинаковыми. Если одна команда пишет «test», другая — «checks», а третья — «ci-test», данные быстро превращаются в хаос.

Не менее важен и контекст. Сохраняйте тип ветки, время суток и пул раннеров для каждой задачи. Сборка в feature-ветке в 10 утра может вести себя совсем иначе, чем та же сборка в main прямо перед релизом. Если один пул обслуживает сборки, а другой — тестовые задачи, это разделение должно быть видно в данных с первого дня.

Не останавливайтесь на среднем значении. Отслеживайте p50 и p95 для времени ожидания и времени выполнения. p50 показывает обычный день. p95 показывает медленные, раздражающие случаи, из-за которых инженеры думают, что весь CI-сервис застрял. Среднее в 40 секунд может скрывать p95 в восемь минут, а именно такую цифру люди и запоминают.

Небольшой пример хорошо показывает разницу. Если integration tests выполняются шесть минут, но каждый будний день около 14:00 ждут в общей очереди по семь минут, то тесты — не первая проблема. Проблема в очереди. А значит, исправление меняется: не переписывать тесты, а переносить работу или добавлять мощность там, где задержка действительно начинается.

Если вы уже собираете CI-логи, обычно это требует меньше усилий, чем ожидают люди. Самое сложное — дисциплина: использовать одни и те же метки, сохранять названия пулов и разбирать цифры каждую неделю. Когда эти базовые правила на месте, задержки в очереди перестают прятаться.

Группируйте задачи по классу

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

Лучше группировать по назначению. Спросите, что делает задача, как долго она работает, как часто запускается и насколько неприятным для инженеров будет ожидание. Первую версию держите простой. Если в первый же день создать десять классов, графики превратятся в шум, и никто им не поверит.

Хорошо работает короткий список: проверки pull request для linting, unit-тестов и небольших сборок; длинные тесты для integration, browser или end-to-end наборов; release-сборки для тегированных версий, контейнерных образов и шагов подписи; и фоновые задачи для ночных сканов, миграций и другой не срочной работы.

Так данные очереди читать гораздо проще. Если проверки pull request ждут шесть минут, команда это быстро заметит. Если ночной скан ждёт шесть минут, это может вообще не иметь значения. Одно среднее число по очереди смешивает оба случая и скрывает реальную проблему.

Помещайте flaky длинные тесты в отдельный класс, даже если они живут в том же пайплайне, что и обычные тесты. Они часто повторяются, выполняются дольше ожидаемого и скапливаются в самый неподходящий момент. Как только вы их изолируете, можно ограничить параллельность или перенести их в отдельный пул раннеров, не замедляя каждый pull request.

Release-сборки тоже заслуживают отдельного класса. Им часто нужно больше CPU, больше диска или более строгая работа с секретами. Если они делят раннеры с повседневными pull request, несколько крупных сборок могут заблокировать десятки маленьких проверок. Это плохой обмен, когда разработчикам нужны быстрые ответы, чтобы продолжать работу.

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

Как пошагово отслеживать время ожидания

Начните с данных пайплайна за одну неделю. Обычно этого достаточно, чтобы увидеть закономерность, но всё ещё мало, чтобы разбирать вручную в таблице.

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

Затем дайте каждой повторяющейся задаче простой класс. Используйте понятные людям названия, например unit_tests, integration_tests, build_image, lint или preview_deploy. Не делайте список слишком детальным. Если у каждой задачи будет свой класс, закономерность исчезнет.

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

Когда метки будут на месте, постройте график времени ожидания по классам и по часам дня. Достаточно обычной столбчатой диаграммы или heatmap. Вам нужно увидеть, какие классы ждут дольше всего и когда именно ожидание резко растёт. Среднее время ожидания может скрывать боль, поэтому добавьте медиану и верхнюю границу, например самые медленные 10 процентов.

Затем отметьте классы, которые чаще всего тормозят слияния. Ожидание в 10 минут для ночного security-скана может раздражать, но ожидание в четыре минуты для каждого pull request хуже. Сосредоточьтесь на задачах, которые стоят перед инженерами весь день.

Одна команда может обнаружить, что build_image ждёт по шесть минут около обеда, а unit_tests — всего 40 секунд. Другая, наоборот, увидит, что тесты копятся каждое утро, потому что все отправляют код одновременно. Проблема CI в обоих случаях одна и та же, но решение будет разным.

Прежде чем покупать больше раннеров, разберите две самые болезненные точки. Многие команды слишком рано переходят к увеличению мощности. Часто более дешёвое решение — разделить один шумный класс, перенести тяжёлую задачу в более дешёвый пул или убрать раздутый шаг, который не должен выполняться при каждом слиянии.

Простой пример из растущей команды

Уберите затор в часы пик
Разберите дневные пики, лимиты раннеров и группировку задач с опытным CTO.

Продуктовая команда выросла с шести инженеров до 14 за несколько месяцев. Их CI-настройка за ними не успела. Все использовали один общий пул раннеров для unit-тестов, browser-тестов и release-сборок.

Сначала проблема выглядела случайной. Unit-тесты обычно стартовали меньше чем через 30 секунд, поэтому люди решили, что с очередью всё в порядке. Но browser-тесты показывали другую картину. Примерно в середине дня эти задачи часто стояли в очереди по 18 минут ещё до того, как начинали выполняться.

Несколько дней логов сделали закономерность очевидной. Каждый день после обеда release-сборки начинали накапливаться в том же пуле. Эти сборки были тяжёлыми и работали долго, поэтому занимали слоты раннеров как раз тогда, когда команда отправляла больше всего pull request.

Эта смесь вредила сильнее, чем казалось. Разработчик мог быстро получить обратную связь по unit-тестам, а потом почти 20 минут ждать, чтобы понять, что browser-тест провалился на checkout-flow или странице входа. Очередь не выглядела сломанной, но уже серьёзно съедала время.

Команда изменила две вещи. Они перенесли browser-тесты в более дешёвый пул раннеров с более долгим временем запуска, а release-сборкам выделили собственные раннеры вместо того, чтобы делить их с общим пулом.

Первое изменение сначала показалось странным. Зачем переносить flaky, медленные browser-тесты на раннеры, которые дольше запускаются? Потому что эти тесты и так выполнялись несколько минут, так что лишняя минута на старт почти ничего не решала. Важно было держать их подальше от коротких задач, которым нужна быстрая обратная связь.

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

На следующей неделе показатели стали заметно лучше. Время ожидания browser-тестов упало с 18 минут примерно до четырёх. Unit-тесты по-прежнему запускались быстро. Release-сборки завершались на своих раннерах, не забивая очередь для остальных.

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

Разносите медленную работу по более дешёвым пулам

Самый быстрый способ уменьшить боль от очереди — перестать относиться ко всем задачам одинаково. Lint-проверка, которая должна завершаться за 90 секунд, не должна стоять позади 40-минутного browser-набора или тяжёлой сборки образа.

Держите путь быстрой обратной связи на небольших раннерах, которые постоянно онлайн. Эти раннеры должны обслуживать задачи, результаты которых разработчики смотрят сразу после каждого commit: lint, type checks и unit-тесты. Тёплые кэши и нулевая задержка старта здесь важнее, чем минимальная цена.

Длинные задачи лучше подходят для burst-пулов. Если end-to-end набор занимает 25 минут, обычно есть смысл отправлять его на более дешёвые раннеры с более медленным запуском. Дополнительная минута-две в начале больнее всего ударила бы, если бы она заставляла все короткие задачи ждать в той же очереди.

На практике у многих команд в итоге получается три пула: всегда доступный пул для lint, type checks и unit-тестов; burst-пул для integration и browser-тестов; и отдельный тяжёлый пул для сборок, упаковки и релизных артефактов.

Одна очень большая тестовая задача всё равно создаёт проблемы, даже если вы перенесли её в более дешёвый пул. Разбейте её на более мелкие части с похожим временем выполнения. Если один shard завершается за шесть минут, а другой — за 28, команда всё равно ждёт медленный shard, а очередь всё равно раздувается, когда несколько пайплайнов стартуют одновременно.

Используйте данные прошлых запусков, чтобы сбалансировать эти части. Группируйте тесты так, чтобы каждая задача занимала примерно одинаковое время, а потом отправляйте их в burst-пул. Это одно из самых простых исправлений, потому что оно уменьшает и ожидание, и общее время прохождения.

Тяжёлые шаги сборки тоже должны жить отдельно от быстрых проверок. Сборка контейнеров, упаковка мобильных приложений и компиляция ресурсов могут идти в своём пуле и запускаться только после прохождения lint и unit-тестов. Так одна сломанная инструкция импорта или простая ошибка теста не съедает дорогую мощность сборки.

Но расходы всё равно могут выйти из-под контроля, если позволить каждому пулу бесконечно масштабироваться. Задайте жёсткий лимит для каждого burst-пула. Растущая команда может держать двух всегда доступных раннеров для быстрых проверок, разрешить пулу browser-тестов масштабироваться до шести дешёвых рабочих машин и оставить release-сборки на одном изолированном builder. Такая схема сохраняет быструю обратную связь и не даёт одному шумному дню удвоить счёт.

Ошибки, которые скрывают узкое место

Получите поддержку Fractional CTO
Привлеките практическую помощь по CI/CD, инфраструктуре и AI-first разработке.

Большинство проблем с очередью остаются незамеченными, потому что команды сначала хватаются не за то решение. Они добавляют больше раннеров до того, как измерят время ожидания. Это кажется практичным, но часто скрывает реальную проблему на неделю-две и сразу повышает расходы.

Дополнительная мощность почти не помогает, если build-задачи, test-задачи и deploy-задачи все конкурируют за один и тот же пул. Один шумный класс работы всё равно может блокировать всё остальное. Нужно понимать, кто ждёт, как долго ждёт и когда очередь начинает расти.

Проблемы также появляются, когда deploy-задачи смешивают с обычным тестовым трафиком. Deploy случаются реже, но для них важен момент запуска. Обычные тестовые задачи идут постоянно. Если они делят одни и те же раннеры, обычный всплеск коммитов после стендапа может задержать релиз, а релиз может замедлить обратную связь для всей команды.

Ещё одна частая ошибка — держать одну гигантскую тестовую задачу, потому что на дашборде это выглядит проще. Читать действительно проще, но исправлять сложнее. Одна большая задача ждёт дольше, выполняется дольше и скрывает, какая именно часть набора тратит время.

Разбейте эту работу на более мелкие группы тестов по сервису, пакету или типу тестов. Затем сравните время ожидания и время выполнения для каждой группы. Обычно вы быстро найдёте один тяжёлый фрагмент, который стоит вынести в отдельный пул, часто на более дешёвых раннерах.

Время суток важнее, чем думают многие команды. В среднем за день очередь может выглядеть нормально, но ощущаться ужасно с 10:00 до 11:00 или сразу после обеда. Если пять человек одновременно отправляют код, инженеры могут терять по 10 минут на каждую сборку, хотя дневной отчёт говорит, что всё в порядке.

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

Быстрый разбор обычно показывает настоящую проблему. Отслеживайте время ожидания отдельно для build, test и deploy задач. Сравнивайте загруженные часы, а не только дневные средние значения. Делите слишком большие тестовые задачи, прежде чем покупать больше мощности. Убирайте deploy-трафик из того же пула, где идут обычные тесты. А flaky-тесты разбирайте уже после того, как поймёте, что очередь — не главный тормоз.

Как только вы измеряете очередь, а не гадаете, узкое место перестаёт казаться случайным. Тогда можно тратить деньги там, где они действительно уменьшают задержку.

Короткий чек-лист здоровья очереди

Сделайте быстрые проверки быстрыми
Настройте пулы для lint, unit-тестов и длинных наборов тестов по понятным правилам.

Здоровая очередь кажется скучной. Инженер открывает pull request, быстрые проверки почти сразу стартуют, и никто не спрашивает, почему «CI медленный», если самому коду нужно всего несколько минут на выполнение.

Короткого еженедельного обзора обычно достаточно. Большинство pull request-задач должны стартовать примерно в течение двух минут. Длинные тестовые наборы и тяжёлые сборки должны быть отделены от быстрых проверок вроде lint, type checks и небольших unit-тестов. Вы должны уметь без догадок назвать три класса задач с худшими median и p95 по ожиданию. После каждого изменения раннеров сравнивайте время очереди до и после. И подбирайте расходы под пики спроса, а не под дневное среднее.

Небольшая команда может делать это за 10 минут в неделю. Возьмите данные за последние семь дней, сгруппируйте задачи по классам и посмотрите на закономерности по часам. Для поиска проблем не нужен огромный дашборд. Часто достаточно простой таблицы с классом задачи, числом запусков, медианным ожиданием и p95 ожиданием.

Одна деталь важнее, чем многие ожидают: отделяйте «медленно, потому что долго выполняется» от «медленно, потому что долго ждёт». Именно в этом зазоре и прячутся узкие места очереди. 20-минутная тестовая задача может быть нормальной, если она сразу стартует на дешёвом пуле. А трёхминутная проверка, которая ждёт восемь минут, — это та задача, которую разработчики будут ненавидеть.

Команды, которые работают с экономной инфраструктурой, обычно выигрывают от нескольких чётких правил, а не от постоянной ручной настройки. Это также стиль, который Oleg Sotnikov приносит в работу с AI-first delivery и инфраструктурой: измерьте задержку, назовите перегруженные классы задач, а затем перемещайте только ту работу, которая сильнее всего мешает разработчикам.

Следующие шаги для более лёгкой CI-конфигурации

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

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

Рабочий план может быть простым. Оставьте проверки pull request на самом быстром пуле. Перенесите длинные sharded-тесты и большие image-сборки в более дешёвые пулы. Отделите release-задачи от ежедневного трафика разработчиков. Назовите каждый пул по назначению, чтобы людям не приходилось гадать. Разбирайте данные очереди каждую неделю, пока время ожидания не станет низким.

Запишите правила для пулов рядом с конфигурацией CI, а не в отдельном документе, который никто не открывает. Новые задачи часто попадают в пул по умолчанию просто потому, что так проще, а не потому, что это правильно. Короткий комментарий в файле пайплайна может сэкономить часы задержки каждую неделю.

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

Если после всех базовых изменений команда всё равно видит долгие ожидания, поможет второе мнение. Oleg Sotnikov на oleg.is работает как Fractional CTO и анализирует CI/CD-раннеры, расходы на облако и структуру пайплайнов для стартапов и небольших компаний. Такой разбор лучше всего работает, когда у вас уже есть неделя данных по очереди и один-два болезненных примера, потому что тогда разговор остаётся конкретным, а исправления обычно оказываются довольно небольшими.

Часто задаваемые вопросы

Как понять, что CI тормозит из-за очереди, а не из-за тестов?

Отслеживайте три показателя для каждой задачи: время ожидания в очереди, время выполнения и общее время пайплайна. Если ожидание велико, а время выполнения остаётся нормальным, проблема в очереди, а не в тестах.

Какие показатели нужно измерять в первую очередь?

Начните с времени ожидания в очереди, времени выполнения и общего времени от push до результата. Затем добавьте класс задачи, пул раннеров, тип ветки и час дня, чтобы увидеть, где накапливаются задержки.

Какое ожидание в очереди считается слишком большим для pull request?

Для pull request-проверок старайтесь, чтобы задачи запускались примерно в течение двух минут. Проверка на три минуты, которая ждёт восемь минут, раздражает сильнее, чем длинный тест, который стартует сразу.

Как лучше группировать CI-задачи при анализе задержек в очереди?

Группируйте задачи по тому, что они делают, а не по репозиторию или команде. Простые классы вроде lint, unit_tests, integration_tests, build_image и deploy помогают легче увидеть узкое место.

Стоит ли сразу добавлять больше раннеров, если очередь растёт?

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

Какие задачи лучше держать на всегда доступных раннерах?

Оставьте быстрые проверки на всегда доступных раннерах. Lint, type checks и небольшие unit-тесты должны стартовать быстро, потому что разработчики смотрят на эти результаты сразу после каждого push.

Когда стоит переносить задачи в более дешёвые пулы раннеров?

Используйте более дешёвые burst-пулы для длинных задач, которые и так выполняются несколько минут, например browser, integration или end-to-end тестов. Дополнительная минута запуска там обычно менее болезненна, чем блокировка коротких проверок в той же очереди.

Должны ли release-сборки делить раннеры с обычными тестовыми задачами?

Нет. Release-сборки часто требуют больше CPU, диска или секретов и могут блокировать десятки мелких проверок. Лучше дать им отдельный пул, чтобы ежедневная разработка не замедлялась.

Зачем делить одну большую тестовую задачу на несколько частей?

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

Какой самый простой первый шаг, чтобы найти узкое место?

Возьмите данные за одну неделю, посчитайте время ожидания от created до started, пометьте повторяющиеся задачи по классам и постройте график ожидания по классам и часам. Затем исправьте одну-две самые болезненные точки и проверьте результат на следующей неделе.