← назад к разделу

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

CI/CD — это автоматический конвейер, в котором каждый коммит проходит один и тот же путь от изменения до прода. Человек в этом процессе принимает решения («мержим?», «выкатываем?»), а не выполняет руками каждый шаг.

коммит ушёл в общую ветку — дальше считает машина сборка +22 мин unit-тесты +35 мин интеграционные +1823 мин деплой на стенд +427 мин smoke-тесты +532 мин терпения хватает примерно на 15 минут разработчик взялся за другую задачув голове уже другая задача ✗ тест упал — узнали через 32 минутывозвращаться к правке и вспоминать её заново

Внутри полосы — сколько минут занял шаг, справа от неё — сколько прошло с коммита. Быстрые проверки отвечают на пятой минуте, а вердикт по коммиту — на тридцать второй. К этому моменту разработчик уже пятнадцать минут занят другим, и вместе с упавшим тестом он получает вторую работу: вспомнить, что там менял.

Обязательно

Из чего состоит конвейер

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

коммит попал в общую ветку сборка собрали один раз быстрые проверки unit, линт медленные проверки интеграционные артефакт образ с хешем стенд выкат автоматом проверка стенда smoke-тесты прод тот же образ то же изменение едет дальше

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

Имена стадий у каждой команды свои, но логика одна: чем дороже стадия по времени — тем позже в цепочке.

Артефакт собирается один раз

Самая частая ошибка: собирать код заново для каждого окружения. «Для staging — эта сборка, для прода — соберём ещё раз, уже правильно».

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

Правильный подход называется build once, deploy many: образ собирается один раз на коммит и помечается его хешем (SHA). Именно этот образ едет и на стенд (staging, он же тестовое окружение), и в прод. Различия между окружениями — только в конфигурации снаружи (переменные окружения, секреты, адреса баз данных), но не в самом артефакте.

Правило ломается ровно там, где что-то про окружение зашивают внутрь на этапе сборки: профиль прямо в Dockerfile, -Dspring.profiles.active=stage в команде запуска внутри образа, собранный фронт с прошитым адресом API. После этого образ перестаёт быть одинаковым для stage и прода, и для второго окружения его придётся собрать заново. Проверка простая: если для другого окружения нужна пересборка — build once уже не соблюдается, как бы это ни называлось в документации команды.

Практически это выглядит как продвижение одного артефакта: сборка при слиянии в main кладёт образ в реестр с неизменяемым тегом и дайджестом; stage получает этот дайджест, прогоняет проверки, и тот же дайджест записывается в манифест prod. Релиз — изменение указателя на уже проверенный артефакт, а не новая сборка; откат — возврат к предыдущему дайджесту, который уже собирали и проверяли.

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

Быстрое — раньше медленного

Вернёмся к конвейеру с картинки в начале: сборка и unit-тесты занимают 5 минут, дальше идут интеграционные, выкат на стенд и smoke — ещё 27. Теперь представьте, что всё это собрано в один кусок и вердикт объявляется в конце. Unit-тест упал на пятой минуте, а разработчик узнает об этом на тридцать второй: оставшиеся двадцать семь минут конвейер честно проверял код, про который уже известно, что он сломан.

Принцип fail fast: самые быстрые проверки идут первыми, и каждая обрывает конвейер сразу, а не копит претензии до конца.

  • Компиляция и unit-тесты — минуты. Их запускают сразу.
  • Интеграционные тесты — десятки минут. Только если быстрые прошли.
  • Деплой на staging и smoke-тесты — после интеграционных.

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

Конвейер описан в коде

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

Хороший вариант — pipeline as code: конвейер описан файлом в репозитории рядом с кодом. В GitHub Actions это .github/workflows/, в GitLab — .gitlab-ci.yml, в Jenkins — Jenkinsfile. Этот файл версионируется, рецензируется и откатывается так же, как и любой другой код.

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

name: ci
on:
  pull_request:
  push:
    branches: [main]
jobs:
  checks:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make lint
      - run: make test
  image:
    needs: checks
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: make image TAG=${{ github.sha }}

Читается сверху вниз. on — когда конвейер запускается: на каждый pull request и на каждое попадание в main. jobs — задания, по умолчанию они идут параллельно; needs: checks у второго задания выстраивает порядок: образ собирается только после того, как проверки прошли. if с проверкой ветки — то самое разделение «на PR только проверяем, в реестр публикуем только с общей ветки»: без этой строки образ полетит в реестр с любой ветки любого автора. Имя образа — хеш коммита, и это ровно то самое build once: тег привязан к изменению, а не к дате сборки.

Синтаксис у каждой системы свой: в GitLab это ключи stages и script, в Jenkins — блоки stage в Jenkinsfile. Устройство одинаковое: события, задания, зависимости между ними, условия. Поэтому переход между системами — это перевод файла, а не перепроектирование процесса.

Зелёный main — основа командной работы

Если main сломан, никто в команде не может ни собраться, ни задеплоиться. Работа стоит.

Отсюда несколько правил:

  • Изменение попадает в main через проверку, а не молча. Обычная форма — pull request; в командах с trunk-based мелкие правки иногда коммитят в main напрямую, но тогда вместо ревью после факта работает парное программирование, то есть вторая пара глаз есть всё равно.
  • PR мержится только тогда, когда конвейер зелёный: красный PR, влитый «потом починим», ломает main для всех, кто обновится следующим.
  • Если main всё же сломался — чиним немедленно и в приоритете, потому что каждый час красного main умножается на число людей в команде. Откат сломанного коммита (revert) быстрее расследования: он возвращает всем рабочий main за минуту, а разбираться можно после.

Команда, у которой main красный неделями, по факту не имеет CI: у неё есть сервер, который красиво показывает, что всё сломано.

Есть неприятность, из-за которой эти правила сами по себе main не удержат. Два pull request могут быть зелёными по отдельности и красными вместе: один переименовал метод, второй в это же время добавил новый вызов старого имени. Каждый проверялся на своей копии main — на той, где второго изменения ещё не было. После двух слияний main красный, и виноватых нет: оба сделали всё правильно.

Лечится это очередью слияния: система выстраивает одобренные pull request в цепочку и перед слиянием каждого прогоняет проверки на результате слияния — то есть на том состоянии, которое реально получится. Красный выпадает из очереди, остальные едут дальше. В командах из трёх человек это избыточно, потому что вероятность совпадения мала; на десяти и больше это единственное, что держит зелёный main без ручного дежурства. Как это устроено и чем ещё защищают общую ветку — в статье про ветки и релизный цикл.

Деплой и релиз — это разные вещи

Многие боятся деплоить часто, потому что деплой = «пользователи сразу видят новое». Если что-то сломается — страшно.

Но деплой и релиз можно разделить:

  • Деплой — технический шаг: новый код оказался на сервере.
  • Релиз — продуктовый шаг: функция стала доступна пользователям.

Инструмент разделения называется feature flags (флаги функций): новый код едет в прод выключенным, включается отдельной командой, выключается без отката кода.

Это снимает главный страх: деплоить можно часто и спокойно. Релизить — когда готова функция с точки зрения продукта, а не когда «собрался смелости».

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

Окружения должны быть одинаковыми

Staging (тестовое окружение) нужен именно для того, чтобы проверить то, что поедет в прод. Если staging устроен иначе — он проверяет что-то другое.

Частые причины расхождения:

  • На staging — Docker, на проде — руками на сервере.
  • Разные версии баз данных или промежуточного программного обеспечения.
  • Конфигурация staging «примерно как в проде, но вручную».

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

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

Что с этим делают. Первое — знают список отличий и держат его написанным, а не в памяти: тогда после сбоя на проде первый вопрос «а это не то ли, чем стенд отличается?» задаётся за минуту. Второе — то, что стенд принципиально не проверяет, проверяют другими средствами: производительность запросов — на контуре с данными в форме прода (обезличенными), поведение при отказе внешней системы — подменой ответов и искусственными ошибками, а не ожиданием, что провайдер сломается на стенде. Третье — расхождения, которые ничего не стоят, устраняют сразу: одинаковая версия базы, тот же образ, тот же способ запуска. Одинаковость окружений — не состояние, а работа, и её объём стоит выбирать осознанно.

Миграции базы — часть конвейера

В таблице частых ошибок есть строка «забыл накатить миграцию», и она оставляет главный вопрос без ответа: а кто должен накатывать и когда? Схему базы правят почти в каждом втором изменении, и именно здесь ломается всё остальное — и build once, и «откат за секунды».

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

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

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

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

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

Если выкат откатили. Здесь и живёт самая дорогая ошибка темы: откат кода и откат данных — разные операции. Код откатывается возвратом указателя на предыдущий образ, за секунды, и это безопасно. Миграция так не откатывается: «обратный» шаг у добавления колонки — это её удаление, то есть удаление данных, которые новая версия успела записать. Поэтому правило простое: миграции при откате не откатывают. Откатывают код, а несовместимость лечат вперёд — новой миграцией.

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

Конвейер — это тоже система, которую поддерживают

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

Конвейер мониторится, его сбои разбираются так же, как инциденты в продакшне.

Чем конвейер опасен: секреты и цепочка поставки

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

Короткоживущий токен вместо ключа. Долгоживущий ключ доступа в переменных конвейера — самый частый способ потерять всё: он лежит там годами, его видят все, кто может править файл конвейера, и его утечка не имеет срока давности. Современная замена — федерация доверия: облако или реестр заранее договариваются с системой сборки о том, каким именно её заданиям они верят (репозиторий, ветка, окружение), и на время прогона выдают токен на несколько минут. Постоянного ключа в конвейере не остаётся вовсе, а украденный токен бесполезен к моменту, когда его успеют применить.

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

Сборки из форков. Изменение, присланное из чужой копии репозитория, — это код, который написал кто угодно. Если такому прогону отдать секреты, он их просто распечатает. Поэтому проверки на таких pull request идут без секретов и без права публикации, а шаги, которым секреты нужны, запускаются только после одобрения человеком и только на своём коде.

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

Вторая половина темы — цепочка поставки: гарантия, что в проде работает именно то, что собрал ваш конвейер из вашего кода. Здесь четыре вещи. Состав артефакта (SBOM) — список всех зависимостей, вошедших в образ: без него на вопрос «а у нас есть эта уязвимая библиотека?» команда отвечает сутками. Подпись артефакта при сборке и проверка подписи перед выкатом — тогда образ, положенный в реестр мимо конвейера, в прод не поедет. Неизменяемые теги в реестре: если тег можно перезаписать, «тот же образ, что тестировали» перестаёт быть правдой, поэтому выкат ссылается на дайджест, а не на тег. Срок хранения: образы старых выкатов держат ровно столько, сколько может понадобиться откат, и не бесконечно — иначе реестр растёт в деньгах, а уборка однажды сносит то, на что ссылается прод.

Порядок внедрения такой же, как в любой защите: сначала убрать долгоживущие ключи и секреты из логов (дёшево, закрывает большинство реальных случаев), потом состав артефакта, и только потом подпись с проверкой. Как это выглядит шагами конвейера — в разделе «Глубже: шаги конвейера для своего стека» ниже, а для Java подробно в статье про CI.

Частые ошибки

ОшибкаЧто происходитКак правильно
Пересборка артефакта для продаНа прод едёт не то, что тестировалосьСобрать один раз, пометить SHA
Конвейер 40+ минутРазработчики пушат пачками, CI как формальностьОриентир ~15 мин; параллелизм, кеши
Конвейер настроен кликами в UIНевозможно воспроизвести и рецензироватьФайл в репозитории
Красный main «потом починим»Команда заблокированаОткат немедленно, разбор после
Релиз = деплойСтрах деплоев, ночные релизные окнаFeature flags: деплоить часто, релизить осознанно
Staging «почти как прод»Проверяется не то, что поедетОдинаковые декларации, разный конфиг
Ручные шаги между стадиями«Забыл накатить миграцию»Весь путь автоматизирован
Миграция и код в одном шагеОткат кода ломается о новую схемуСхема отдельным шагом, изменения совместимые
Долгоживущий ключ в переменных CIУтечка без срока давностиКороткоживущий токен по федерации
Дополнительно: при первом чтении можно пропустить

Глубже: четыре метрики доставкирасширенное

«Время конвейера это такая же метрика, как время ответа сервиса» верно, но одной метрики мало. Есть четыре, по которым отрасль давно сравнивает команды, и они измеримы из данных, которые у вас уже есть.

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

Ориентиры из отраслевых исследований: сильные команды выкатывают по требованию, несколько раз в день, время от коммита до прода меньше дня, неудачных изменений около 5 %, восстановление меньше часа. Команда с недельным циклом и 20 % неудачных выкатов не «отстающая», а с понятной точкой роста, и это точка роста конвейера, а не людей.

Как снимать. Частота и время до прода считаются из событий выката (конвейер пишет их в метрики или в таблицу: коммит, окружение, время) и истории git. Доля неудачных и восстановление требуют связать инциденты с выкатами: у каждого инцидента поле «вызван изменением», у каждого отката причина. Считают по команде и по сервису, смотрят на тренд за квартал, а не на число за неделю. Что портит метрики: их превращение в цель (частоту накручивают пустыми выкатами), сравнение команд с разной природой сервисов и попытка измерять людей вместо конвейера.

Глубже: контуры, препрод и ручные воротарасширенное

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

Разработка: у каждого разработчика своя или временный контур на pull request, который поднимается из того же кода инфраструктуры и удаляется при закрытии PR; там ломают. Тестовый: общий контур для интеграционных проверок с настоящими соседями и очередями, данные синтетические, выкат на каждое слияние в main. Препрод: копия прода по размерам и настройкам, с обезличенными данными в форме прода и теми же внешними интеграциями в тестовом режиме; сюда попадает ровно тот артефакт, что пойдёт в прод, здесь гоняют нагрузочные тесты перед выпуском (о них статья про нагрузочное тестирование) и репетируют миграции. Прод. Отличаются контуры только параметрами: размерами, адресами, секретами, а код инфраструктуры и образ одни; препрод, который «немного не как прод», не ловит ничего.

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

Окна выката. Не выкатывать в пятницу вечером и в час пик это не суеверие, а расчёт: если что-то пойдёт не так, нужны люди и запас времени, и цена отката в часы низкой нагрузки ниже. Окна записаны в настройках конвейера, а не в памяти; исключения (исправление инцидента) проходят по отдельной, короткой процедуре с ведома дежурного. Заморозка выкатов на праздники и распродажи из той же логики, и её тоже объявляют заранее.

Глубже: конвейер для монорепозитория и многомодульной сборкирасширенное

Один репозиторий на десять сервисов или проект из двадцати модулей (Gradle, рабочая область Go, npm или uv) ставит вопрос, который в статье про CI для одного сервиса не возникает: правка в одном модуле не должна пересобирать и перепроверять всё.

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

Затронутые модули по графу. Точнее считать от графа зависимостей: git diff против main даёт изменённые файлы, по ним определяют модули, а по графу зависимостей всех, кто от них зависит, и собирают только их. Для Gradle это делают плагины или небольшой скрипт, в Go обратный граф считают по go list -deps ./..., у pnpm это фильтр --filter "...[origin/main]", у Bazel и Nx это встроено. Результат передают матрице заданий: по одному на затронутый сервис, параллельно.

Общий кеш. Даже при сборке всего репозитория удалённый кеш сборки (Gradle build cache, Nx или Turborepo для Node) делает нетронутые модули бесплатными: задача с теми же входами берёт результат из кеша вместо выполнения. Ключ считается от содержимого входов, и кеш один на все ветки и раннеры. У Go кеш сборки и тестов встроен в go build и go test, но живёт на машине; в конвейере его сохраняют между запусками вместе с кешем модулей, а с Go 1.24 через GOCACHEPROG подключают и внешний. У Python кешируют окружение по хешу lock-файла: пересобирать его на каждый запуск дороже самих тестов.

Что общее, а что раздельное. Версии зависимостей одни на репозиторий (каталог версий Gradle, один go.work или общий go.mod, один lock-файл рабочей области npm, pnpm или uv), иначе десять сервисов расходятся по версиям библиотеки за месяц; выкат раздельный: правка в одном сервисе выкатывает только его, и версия у каждого сервиса своя. Проверки кода и линтеры идут по всему репозиторию, но они быстрые. И один общий бюджет времени на PR: если проверка затронутых модулей не укладывается в десять минут, это повод резать граф зависимостей, а не терпеть.

Глубже: шаги конвейера для своего стекарасширенное

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

Для Java это отдельная статья, CI для Java/Spring: Gradle с удалённым кешем и кешем конфигурации, Testcontainers в конвейере, гейты Dependency-Check, Gitleaks и Trivy, SBOM и подпись образа. Здесь важно одно: тот же порядок, что и у остальных стеков, сначала быстрые проверки, потом тесты, потом артефакт, и всё это в одном задании на PR с бюджетом в десять минут.

В Go конвейер короткий, потому что почти всё встроено в go: сборка, тесты, проверка гонок, кеш. Версия языка берётся из go.mod, а не из настроек конвейера, иначе они разойдутся.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod      # версия из go.mod; кеш модулей и сборки включён по умолчанию
      - run: go vet ./...
      - uses: golangci/golangci-lint-action@v8
        with:
          version: v2.1
      - run: go test -race -shuffle=on -count=1 -coverprofile=cover.out ./...
      - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...
      - run: CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bin/app ./cmd/app

Что здесь важно. setup-go сохраняет между запусками и кеш модулей, и кеш сборки по хешу go.sum, поэтому второй запуск компилирует только изменённое. -count=1 выключает кеш результатов тестов: в конвейере тесты должны выполниться, а не взяться из прошлого запуска, -shuffle=on ловит тесты, которые зависят от порядка. -race требует cgo и замедляет тесты в несколько раз, и это нормальная цена в CI, а вот бинарник собирают отдельно с CGO_ENABLED=0: статический файл кладут в образ от scratch или distroless. govulncheck смотрит не на список зависимостей, а на то, какие уязвимые функции реально достижимы из кода, поэтому шумит куда меньше сканеров по манифесту. Testcontainers работают как везде: на раннере есть Docker, тесты сами поднимают PostgreSQL.

В Node главный источник невоспроизводимости это npm install, который может обновить lock-файл прямо в конвейере. Поэтому в CI только npm ci: он ставит ровно то, что в package-lock.json, и падает, если lock разошёлся с package.json.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm                   # кеш ~/.npm по хешу package-lock.json
      - run: npm ci
      - run: npm run lint              # eslint и prettier --check
      - run: npx tsc --noEmit
      - run: npm test -- --run --coverage
      - run: npm audit --audit-level=high
      - run: npm run build

Что здесь важно. Кешируют не node_modules, а кеш загрузок ~/.npm: npm ci всё равно удаляет node_modules и ставит заново, зато из кеша, без сети. Проверка типов идёт отдельным шагом до тестов: vitest и jest выполняют TypeScript через транспиляцию без проверки типов, и без tsc --noEmit ошибка типов доедет до прода. npm audit шумит на транзитивных зависимостях без пути эксплуатации, поэтому порог ставят high, а исключения ведут списком с датой пересмотра, как и флаги. Скрипты установки пакетов это отдельный вектор атаки на цепочку поставок: там, где они не нужны, ставят npm ci --ignore-scripts.

В Python воспроизводимость даёт lock-файл uv.lock и команда uv sync --locked: она собирает окружение ровно по lock-файлу и падает, если он отстал от pyproject.toml, вместо того чтобы молча пересчитать версии.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: astral-sh/setup-uv@v6
        with:
          enable-cache: true           # кеш по хешу uv.lock
      - run: uv python install         # версия из .python-version
      - run: uv sync --locked
      - run: uv run ruff check . && uv run ruff format --check .
      - run: uv run mypy src
      - run: uv run pytest -q --cov=src --cov-fail-under=80
      - run: uv export --no-emit-project -o requirements.txt && uvx pip-audit --disable-pip --no-deps -r requirements.txt

Что здесь важно. Версия интерпретатора берётся из .python-version в репозитории, а не из образа раннера, иначе локально и в CI разные Python. Кеш uv по хешу lock-файла делает установку секундной; без него окружение с компилируемыми пакетами собирается дольше тестов. ruff закрывает и линтер, и форматирование одним инструментом, mypy идёт отдельным гейтом, потому что тесты типы не проверяют. Сканер уязвимостей получает не окружение, а экспортированный из lock-файла список с хешами: так он проверяет ровно то, что уйдёт в образ. Testcontainers для Python работают так же, как в других стеках, раннер уже с Docker.

Коротко

  • CI/CD — автоматический конвейер: один и тот же путь от коммита до прода для каждого изменения. Быстрые проверки идут раньше медленных — так ошибка обнаруживается как можно скорее.
  • Build once, deploy many: артефакт собирается один раз и едет в staging и прод без пересборки. Staging и прод поднимаются из одних деклараций — различается только конфигурация.
  • Конвейер описан файлом в репозитории, рецензируется и версионируется вместе с кодом. Конвейер мониторится и поддерживается как любая другая система.
  • Зелёный main — приоритет команды: сломанный откатывается немедленно.
  • Деплой и релиз разделены: новый код едет в прод выключенным, включается отдельно через feature flags.
  • Четыре метрики доставки: частота выката, время от коммита до прода, доля неудачных изменений, время восстановления; считают из событий выката и инцидентов по сервису за квартал, не превращая в цель.
  • Контуры: разработка, тестовый, препрод как копия прода с обезличенными данными и нагрузкой, прод; отличаются параметрами, а не кодом; ручные ворота нажимает не автор, окна и заморозки записаны в конвейере.
  • Монорепозиторий: собирать затронутые модули по графу зависимостей, а не по фильтрам путей, матрица заданий, удалённый кеш сборки, версии общие, выкат раздельный.
  • Миграции — отдельный шаг до выката кода, в одном выпуске только совместимые изменения; откатывают код, а не миграцию, несовместимость лечат новой миграцией.
  • Конвейер имеет право выката: короткоживущий токен вместо ключа, маскирование в журналах, форки без секретов, состав артефакта и выкат по дайджесту.

Что почитать дальше