Большие старые проекты обычно живут по своим законам.

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

Наш проект был именно таким: монорепозиторий, десятки микросервисов, сотни зависимостей и общие библиотеки для всего подряд. В кодовой базе было около 220 Python-пакетов и примерно 70 Docker-контейнеров, которые собирались из них. Всё хранилось в одном репозитории, а пайплайн для pull request’ов проходил через Azure TFS. Он был разделён на два больших этапа.

На первом прогонялись линтеры, юнит-тесты и выполнялась сборка контейнеров. На втором — запускались интеграционные тесты. Чтобы не собирать все образы каждый раз, использовался скрипт, который с помощью git diff определял, какие пакеты изменились, и по зависимостям вычислял, какие образы нужно пересобрать. Юнит-тесты запускались всегда полностью — это помогало ловить регрессии, даже если код не пересобирался.

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

После нескольких таких случаев мне поручили разобраться и ускорить процесс, не ломая существующую архитектуру пайплайна.

До этого мы решали проблему привычным способом — просто просили у DevOps-команды “пожирнее” сервера. Это действительно помогало: добавляли пару ядер, больше памяти — и какое-то время всё укладывалось в лимит. Но эффект быстро заканчивался.

Через пару месяцев мы снова упирались в ту же стену: 2–3 сервера из общего пула стабильно вылетали за лимит в два часа. В какой-то момент стало понятно, что бесконечно наращивать железо нельзя — и в этот раз решили подойти к проблеме по-другому.


Сначала я попробовал классические способы: оптимизация Dockerfile, кеширование слоёв, очистка зависимостей. Помогало — но незначительно.

Основная проблема была в том, что все сборки шли на одном агенте, и в какой-то момент просто упирались в физические ресурсы. Решением стало распределить процесс. Я предложил использовать Docker Swarm не как систему оркестрации для продакшена, а как “кластер сборки”, способный параллельно собирать образы на нескольких нодах.

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

Вот логика этого процесса, если описать её схематично:

Схема измененного пайплайна
Схема измененного пайплайна

После внедрения параллельной сборки время этапа docker build сократилось с полутора часов до 40 минут.

CPU на агентах перестал простаивать, кэш стал использоваться эффективнее, а разработчики — меньше ждать. Среднее время PR сократилось примерно на два часа.

Если раньше на проверку и доработку одной задачи могло уходить полдня, теперь цикл стал заметно короче — “зелёные флаги” появлялись гораздо раньше, а ревью стало проходить быстрее.

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

Если их оказывалось больше N (например, 40), сборка автоматически запускалась в параллельном режиме. Если меньше — по старинке, локально. Это помогло сбалансировать нагрузку и не занимать кластер по пустякам.

До оптимизации:

  • Линтеры и тесты — 20 мин

  • Полная сборка — 90-100 мин

  • Интеграционные тесты — 60–90 мин

После:

  • Линтеры и юнит-тесты — 20 мин

  • Полная сборка — 40-50 мин

  • Интеграционные тесты — 60–90 мин

→ Средний PR ускорился на ~2 часа

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

P.S.

На моей практике это было самое большое ускорение не в коде, а в том, как мы его собираем.


?‍? Сергей Наталенко

Backend Developer / Python & Go

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


  1. baldr
    11.11.2025 07:22

    Наверное вы молодец, но, честно говоря, статья выглядит просто как "я сделяль", без каких-либо подробностей: "Я ускорил что-то на 2 часа, вот ссылки на мои Github, Telegram, Linkedin".

    Это хабр, сэр. Давайте технические детали. При чём тут Swarm? Как кто-то другой может что-то подобное сделать?


  1. Hackitect7
    11.11.2025 07:22

    Интересно написано, живо и по делу. Проблема реально знакомая - когда вроде всё вылизано, а сборки всё равно полдня тянутся. Хорошо, что автор не пошёл по пути “накидаем железа и успокоимся”, а копнул в архитектуру пайплайна.
    Но, если честно, не совсем ясно, насколько стабильно это всё работает в реальной нагрузке. Параллелка - штука капризная, особенно с кешем и зависимостями. Не ловили ли вы ситуации, когда один сервис пересобрался не с тем артефактом или старым образом?
    В целом, статья хорошая, но хочется чуть больше реальных цифр и подводных камней, а не только общий итог “стало быстрее”. Вот тогда бы разговор получился ещё интереснее.