ИИ-агенты сделали CI узким местом — как Linear перестроила свой CI, чтобы успевать за ними

· 1 мин чтения
ai-coding ci-cd infrastructure productivity
ИИ-агенты сделали CI узким местом — как Linear перестроила свой CI, чтобы успевать за ними

Агенты научились писать код экспоненциально быстрее, но проверяют его по-прежнему через CI. Каждый pull request обязан пройти пайплайн, поэтому чем быстрее разработка, тем сильнее CI превращается в узкое место: инфраструктура дорожает, а разработчики и агенты простаивают в ожидании фидбека. В Linear это заметили в начале года — CTO компании завёл задачу с лаконичным названием «CI costs are high», заодно попросив сделать CI ещё и быстрее.

К сентябрю результат такой: тестовый сайт вырос почти в четыре раза, но время ожидания PR сократилось с более чем 6 минут до чуть больше 5, а машинное время на один тест упало примерно вдвое. Оптимизации велись в четырёх направлениях:

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

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

Инфраструктура и тулчейн

Часть выигрыша не потребовала вообще никакой оптимизации самого CI. Линейка переехала с GitHub Actions на сторонние раннеры с более быстрыми CPU, производительным хранилищем и лучшей инфраструктурой кеширования — тот же пайплайн, просто на более быстрых машинах. Сравнение «день в день» по обе стороны от переключения показало: джобы стали выполняться в среднем на 34% быстрее, а отдельные нагрузки вроде tsc — на 52%.

Отдельную роль сыграло обновление тулчейна. Переход на tsgo, нативный компилятор TypeScript, сократил недельную медиану проверки tsc на 73% — настолько, что бутылочное горлышко полностью сместилось с типчекинга на другие этапы.

Линт без type checker

Линтинг стал следующей целью. Несколько кастомных правил ESLint зависели от информации о типах TypeScript — для их работы каждый прогон линтера строил полный граф типов, из-за чего линтинг оказался самой прожорливой по памяти джобой в CI.

Правила переписали на статический анализ абстрактного синтаксического дерева: поиск конструкций, похожих на функции, и guard-паттернов без обращения к типам. Это позволило ESLint полностью отвязаться от TypeScript: линт API ускорился на 68%, линт всего репозитория — на 55%, потребление памяти заметно упало.

Побочный бонус: правила, работающие только с синтаксисом, легко портируются, что упростило последующий переход на Oxlint — он дополнительно сократил runner-минуты, тратящиеся на линтинг.

Оптимизация гейт-джобов

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

Каждой джобе — только нужный ей код

Джобы change-detection проверяли полный checkout рабочего дерева, хотя им требовалась малая его часть. Ограничение глубины фетча ускорило самую меданную такую проверку с 94 до 20 секунд. Джобам, которым рабочее дерево не нужно вовсе, checkout убрали полностью: с 27 до 7 секунд. Для событий push и merge-queue, где диффать пути всё же нужно, хватило sparse, blobless checkout с ограниченной историей — ещё минус 11 секунд.

Медиана change-detection упала с 26 до 8 секунд, p90 — с 31 до 12 секунд, самый медленный прогон — со 138 до 37 секунд.

Устойчивый к сетевым проблемам checkout

После переезда на сторонние раннеры checkout через actions/checkout стал дольше и иногда зависал. Сторонние раннеры находятся вне сети GitHub и ходят туда по прямому IP-каналу, у провайдера которого обнаружилась периодическая деградация. Поскольку checkout стоит в начале множества воркфлоу, зависший фетч задерживал весь CI-прогон.

Вместо actions/checkout сделали собственную composite action:

  • повтор с backoff при ошибке;
  • переменные GIT_HTTP_LOW_SPEED_LIMIT и GIT_HTTP_LOW_SPEED_TIME, чтобы зависшее соединение обрывалось примерно через 30 секунд вместо бесконечного ожидания;
  • checkout-кеш в виде персистентного git-зеркала на закреплённом диске.

Прогонов, где критически важная джоба простаивала в ожидании checkout, стало значительно меньше.

Минимум на критическом пути

Не все джобы на критическом пути обязаны там находиться. Запись cache-маркеров выполнялась в финальной перед мержем проверке, из-за чего PR мог стоять в merge queue даже после прохождения тестов. Запись перенесли в отдельную джобу, которая стартует после завершения тестовых шардов, но ничего не блокирует: минус 42 секунды с пути мержа для каждого API-PR и каждой записи merge queue.

В сумме эти изменения сняли около минуты с обязательной проверки API-PR при промахе кеша и заодно сократили количество запусков раннеров.

Сокращение повторяемого setup

Следующий слой — фиксированная стоимость каждой джобы: загрузка раннера, установка пакетов, подготовка build-зависимостей. Из-за неё джоба, делающая секунды полезной работы, может съедать минуты инфраструктурного времени.

  • Общий Postgres-клиент предустановили в маленький базовый CI-образ вместе с Node: каждый шард стартует из готового окружения вместо 7–8 секунд установки через apt на каждом прогоне. Позднее туда же добавили нативные build-заголовки — их скачивание на этапе setup иногда зависало, и это подрезало хвост распределения.
  • Монорепозиторий Linear — pnpm workspace, но тестовый воркфлоу API ставил весь workspace, хотя ему нужен только API-пакет с его зависимостями. Ограничение установки сократило pnpm install с 44–73 до 16–18 секунд. Тот же паттерн применили к смежным с API джобам, которые ставили весь репозиторий и заливали кеш зависимостей, почти никогда не попадавший в последующих прогонах.
  • Кеширование node_modules протестировали и выбросили: пересборка оказалась быстрее. Ключ кеша завис от часто меняющегося lockfile, а даже попадание в кеш требовало около 28 секунд на восстановление против 7,5 секунд на фильтрованную установку. Кеш добавлял время сохранения и вариативность, не давая никакой выгоды.

Три первых пункта сократили setup одного шарда примерно на 44%: со 110–140 до 67–73 секунд.

Дальше — виды повторяемого setup, которые можно устранить целиком:

  • API-контейнеры на каждом прогоне проигрывали полную историю миграций базы, даже если PR не менял схему. Для таких случаев перешли на сгенерированный снапшот схемы и bootstrap-файл: подготовка базы упала с ~12 до 1–2 секунд на контейнер.
  • Семь независимых проверок, каждая из которых платила полный setup за секунды полезной работы: свой раннер, свой checkout, своя установка зависимостей. Их объединили в две джобы с семью параллельными задачами внутри. По данным за июнь, изменение сэкономило около 87 000 runner-минут в месяц — 11,8% всего потребления CI в компании.

Эффективное исполнение тестов

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

Балансировка глазами тест-раннера

Vitest распределяет работу по файлам, а не по длительности отдельных тестов. Из-за этого несколько аномально больших файлов доминировали в шарде и фактически удерживали весь сьют, когда остальные шарды давно закончили. Большие файлы разбили на меньшие, сохранив структуру тестов, и перебрали конфигурации шардов и раннеров. Четыре шарда к началу года превратились в восемь: в первичном бенчмарке критическая джоба стала на 19% быстрее и на 19% дешевле, а через неделю после изменения самый медленный шард — с 5,25 до 4,33 минуты.

Общее состояние модулей только с жёсткими правилами

Vitest по умолчанию изолирует каждый тестовый файл, и для Linear это означало пересборку графа сущностей, GraphQL и декораторов в каждом шарде. Ввели opt-in проект с isolate: false, позволяющий безопасным файлам делить реестр модулей внутри воркера.

Это самое большое одиночное улучшение — около 17% месячной экономии при текущем объёме. Самый медленный шард ускорился с примерно 300–379 секунд до ~195, а суммарное runner-время API-шардов на прогон упало с 32,8 до 22 минут.

Одновременно это оптимизация с максимальным риском для корректности. Право на участие сделано явным: opt-in комментарий в каждом файле, плюс необходимая очистка разделяемого состояния. Несколько файлов использовали fake timers или общее состояние так, что безопасно разделить их не получилось, — их оставили в изолированном проекте. А поскольку большинство тестов теперь пишут агенты, соответствующие agent skills обновили: генерируемые тесты по умолчанию соблюдают те же ограничения.

Шарддинг упирается в setup

Дальнейший шарддинг окупается только при низкой фиксированной стоимости шарда: удвоение их количества удваивает и время на setup. При 110–140 секундах на шард восемь шардов потратили бы 15–19 минут runner-времени только на подготовку — больше, чем сами тесты. После оптимизаций setup занимает около 40 секунд, поэтому восемь шардов тратят на него меньше суммарного времени, чем тратили четыре до этого, при вдвое более глубоком параллелизме.

Системный эффект

Метрика До После
Медиана change-detection 26 с 8 с
pnpm install для API 44–73 с 16–18 с
Setup одного шарда 110–140 с 67–73 с
Подготовка БД-контейнера ~12 с 1–2 с
Самый медленный шард API ~300–379 с ~195 с

Без целенаправленной работы над CI нынешний тестовый сьют занимал бы около 11 минут — почти вдвое больше текущего ожидания. И работа не закончена: кодовая база растёт примерно на 2 000 тестов в неделю. Сохранять скорость CI при таком темпе — непрерывный процесс, во многом построенный на применении найденных в этом кейсе приёмов к новым бутылочным горлышкам.

Источник: https://linear.app/now/ci-bottleneck-reworked