Раньше выпуск новой версии выглядел так: разработчик дописывал код, передавал его «команде релиза», та вручную собирала, тестировала и через несколько дней или недель деплоила на сервер. Если что-то шло не так — разбирались долго, ошибались ещё раз.
CI/CD — это автоматический конвейер, в котором каждый коммит проходит один и тот же путь от изменения до прода. Человек в этом процессе принимает решения («мержим?», «выкатываем?»), а не выполняет руками каждый шаг.
Внутри полосы — сколько минут занял шаг, справа от неё — сколько прошло с коммита. Быстрые проверки отвечают на пятой минуте, а вердикт по коммиту — на тридцать второй. К этому моменту разработчик уже пятнадцать минут занят другим, и вместе с упавшим тестом он получает вторую работу: вспомнить, что там менял.
Из чего состоит конвейер
Конвейер — это цепочка стадий. Каждая стадия либо пропускает изменение дальше, либо останавливает его. Частично-зелёного результата нет: либо хорошо, либо стоп.
Каждая стадия либо пропускает изменение дальше, либо останавливает его: частично-зелёного результата нет. Дешёвые по времени проверки стоят раньше дорогих — чтобы очевидная поломка не ждала получаса. На прод едет тот же образ, что проверяли на стенде.
Имена стадий у каждой команды свои, но логика одна: чем дороже стадия по времени — тем позже в цепочке.
Артефакт собирается один раз
Самая частая ошибка: собирать код заново для каждого окружения. «Для 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.
- Четыре метрики доставки: частота выката, время от коммита до прода, доля неудачных изменений, время восстановления; считают из событий выката и инцидентов по сервису за квартал, не превращая в цель.
- Контуры: разработка, тестовый, препрод как копия прода с обезличенными данными и нагрузкой, прод; отличаются параметрами, а не кодом; ручные ворота нажимает не автор, окна и заморозки записаны в конвейере.
- Монорепозиторий: собирать затронутые модули по графу зависимостей, а не по фильтрам путей, матрица заданий, удалённый кеш сборки, версии общие, выкат раздельный.
- Миграции — отдельный шаг до выката кода, в одном выпуске только совместимые изменения; откатывают код, а не миграцию, несовместимость лечат новой миграцией.
- Конвейер имеет право выката: короткоживущий токен вместо ключа, маскирование в журналах, форки без секретов, состав артефакта и выкат по дайджесту.
Что почитать дальше
- CI для Java/Spring: сборка, тесты, quality gates — конкретные шаги конвейера для Java-проекта; для Go, Node и Python шаги собраны в разделе «Глубже» выше.
- Стратегии релиза — что происходит после деплоя на прод.
- Ветки и релизный цикл — как устроен workflow, который питает конвейер.