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

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

main уходит вперёд, пока ветка живёт отдельно расхождение с main main короткая ветка слита на второй день ветка, которую не сливают 2 коммита 5 коммитов 10 коммитов 20 коммитов цена слияния время жизни ветки полминуты1 день 15 минут5 дней час10 дней 4 часа20 дней

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

Обязательно

Почему длинные ветки ломают интеграцию

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

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

Continuous integration в исходном смысле значит именно это: разработчики интегрируют свои изменения в общую ветку часто, каждый день. Не «сервер гоняет тесты», а именно частая интеграция кода.

Trunk-based development

Это подход, при котором существует одна главная ветка (обычно main), и все изменения попадают в неё быстро — через маленькие, короткоживущие ветки. «Короткоживущие» — это день-два, максимум несколько дней, не недели.

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

Старый gitflow — develop-ветка, release-ветки, hotfix-ветки — имел смысл для коробочного ПО, которое выпускалось несколько раз в год. Для сервиса с непрерывной доставкой он добавляет накладные расходы без реальной защиты. Исключение — поддержка нескольких версий одновременно: когда нужно патчить версию 1.x и 2.x параллельно, долгоживущие ветки оправданы. Но это не типичный случай для обычного веб-сервиса.

Прод сломан, а в main десять чужих коммитов

Убрав hotfix-ветки, подход обязан ответить на вопрос, ради которого их держали: прод не работает, исправление на одну строку известно, а в main с момента последнего выката лежат десять несвязанных коммитов, которые выкатывать сейчас нельзя. Ответ есть, и он состоит из трёх вариантов в строгом порядке.

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

Если откатиться нельзя — исправление на том, что в проде. Откат невозможен, когда за выкатом уехала миграция или новая версия чинила другую аварию. Тогда делают ветку от того коммита, который сейчас в проде (у вас есть его хеш — он и есть версия), кладут в неё одну правку, гоняют полный конвейер и выкатывают. Десять чужих коммитов при этом остаются в main и никуда не едут. Сразу после — ту же правку переносят в main отдельным pull request, иначе следующий выкат её затрёт; удобнее наоборот: сначала коммит в main, потом перенос его в ветку от прода, тогда забыть невозможно.

Это не возвращает gitflow. Разница в сроке жизни: ветка от прода живёт сорок минут и удаляется, а не месяцами тянет параллельную историю. Запрет в trunk-based на долгоживущие ветки, а не на ветки вообще.

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

И главное наблюдение: в командах, которые выкатывают каждое слияние, ситуация «в main десять невыкаченных коммитов» не возникает вовсе — прод и main совпадают, и срочное исправление ничем не отличается от обычного pull request, кроме приоритета ревью. Разрыв между main и продом появляется ровно там, где выкат копят. Поэтому настоящее лечение этой боли — выкатывать чаще, а не заводить ветки под срочное.

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

Как работает PR на практике

Trunk-based — это не просто «ветки покороче». Он держится на нескольких командных нормах.

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

Слово «маленький» стоит перевести в цифры, иначе каждый понимает его по-своему. Ориентиры, к которым сходятся команды: до 200 изменённых строк — ревью занимает двадцать-тридцать минут и делается внимательно; 400 строк — верхняя граница, за которой внимание объективно падает, и находки идут уже не по смыслу, а по опечаткам; больше тысячи строк — это не ревью, а одобрение. По файлам ориентир десять, по сущностям одна: если в описании pull request приходится писать «а также», это два pull request. Считать при этом надо только написанное руками: сгенерированный код, файлы блокировок зависимостей и перенос файлов между каталогами в счёт не идут, и их лучше выносить отдельным pull request, чтобы они не топили содержательный диф.

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

Ревью — в течение дня. Если PR висит неделю, автор заблокирован, в его ветке копятся конфликты, мотивация падает. В командах с trunk-based ревью чужого кода стоит выше, чем написание своего нового.

Зелёный конвейер — до ревью. Ревьюер смотрит на дизайн и смысл, а не ловит синтаксические ошибки. То, что ловит машина, пусть ловит машина.

Squash при слиянии. История в main — это лента осмысленных изменений, по одной записи на PR. Не «fix», «fix2», «попробую иначе» — а одна запись с понятным описанием того, что было сделано.

Цена у этого есть, и её стоит знать, раз норма вводится одной строкой. Во-первых, промежуточные коммиты исчезают: если внутри pull request была осмысленная последовательность («сначала механическое переименование, потом изменение логики»), после слияния она сплющится в один коммит, и разбираться в нём через год будет труднее. Для таких случаев нормально выбрать слияние с сохранением коммитов — правило «squash по умолчанию» не запрещает исключений. Во-вторых, squash ломает ветки, ответвлённые от вашей: у автора второй ветки в истории остаются коммиты, которых в main больше нет, и ему придётся переносить работу заново. Поэтому о цепочках связанных pull request договариваются заранее и сливают их по порядку. В-третьих, теряется соавторство: если код писали вдвоём, второй автор исчезает из истории, если его не указать строкой соавторства в теле сообщения. И в-четвёртых, раз запись в истории одна, её текст — это описание pull request, а не последний коммит «правки по ревью»: качество сообщения перестаёт быть личным делом автора.

Feature flags: как прятать незавершённое

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

Альтернатива — feature flag (флаг функции): код уходит в main и деплоится, но функция выключена. Когда продукт решает «всё, готово» — флаг включается, функция становится видимой. Это разделяет деплой кода и релиз функции: деплой происходит часто и безопасно, а включение функции — это отдельное решение.

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

Задача, которую нельзя разрезать

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

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

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

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

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

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

Как назначать версии

Версия «1.0-final-new2», проставленная руками, не отвечает на единственный нужный вопрос: что именно сейчас работает в продакшене. Для сервиса, который деплоится непрерывно, ответ и есть версия — SHA коммита. Каждый коммит в main — потенциальный релиз. SHA однозначно идентифицирует, что именно сейчас работает в продакшене. Проще всего увидеть его в логах при старте, а ещё его отдают служебной ручкой — например, /actuator/info. Правда, не из коробки: сам хеш попадает в сборку отдельным плагином, а ручку надо открыть наружу явно — по умолчанию закрыто всё, кроме проверки живости. Человеческих номеров типа «1.4.2» здесь не нужно.

Одна вещь путается здесь постоянно: тег образа и git-тег — разные сущности, даже когда строка одинаковая. Git-тег — это метка на коммите в истории кода. Тег образа — это имя, под которым артефакт лежит в реестре. Ни одна из этих меток не является надёжным именем артефакта: тег образа в реестре по умолчанию можно перезаписать (и тогда «тот же образ, что тестировали» перестаёт быть правдой), а git-тег можно передвинуть на другой коммит. Надёжное имя у артефакта одно — дайджест, то есть хеш содержимого; он не переписывается по определению.

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

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

SemVer (major.minor.patch) нужен там, где есть внешние потребители контракта: библиотеки, публичные API, события с версиями. Цифры передают смысл изменения:

  • patch — исправление без изменения контракта;
  • minor — новая функциональность, обратная совместимость сохранена;
  • major — ломающее изменение, потребители должны адаптироваться.

Присваивать эти цифры вручную — плохая идея: люди ошибаются и путаются. SemVer автоматически выводится из conventional commits — стандартного формата сообщений коммитов:

feat: добавить метод поиска по тегам
fix: исправить сортировку результатов
feat!: изменить формат ответа API (ломающее изменение)

По такому журналу инструменты сами вычисляют, какую цифру нужно поднять, и генерируют changelog. «Инструменты» — это конкретные вещи, и их стоит назвать, иначе совет остаётся в воздухе: semantic-release и release-please считают версию, ставят git-тег и публикуют заметки к выпуску, запускаясь шагом конвейера на общей ветке; у большинства языков есть свои порты того же подхода (python-semantic-release, в Go версию несёт git-тег, а сборку и заметки делает goreleaser), а для сборок на Gradle и Maven — плагины, которые берут версию из тегов и сообщений вместо файла. Сообщения при этом кто-то должен проверять: без commitlint (или такой же проверки в конвейере) половина коммитов будет без префикса, и версия посчитается неверно. Отсюда простое правило внедрения: сначала проверка формата сообщений, потом автоматическая версия — в обратном порядке получается инструмент, которому нельзя верить.

Одна оговорка — про версии, которые ещё не дошли до 1.0.0. Ноль в начале по правилам SemVer означает «здесь всё может поменяться в любой момент», и инструменты это учитывают: на версии 0.x ломающее изменение (feat!) поднимает не major, а minor — из 0.3.1 получится 0.4.0, а не 1.0.0. Так что пока библиотека живёт на нулевой версии, цифры не значат для потребителей того, чего от них ждут. Хотите, чтобы значили, — выпускайте 1.0.0.

GitOps: что в git — то в продакшене

Традиционный деплой: кто-то запускает скрипт, пишет в чате «деплою», нажимает кнопку. Что сейчас в продакшене — можно узнать только спросив у того, кто деплоил последним. Откат — ручная операция.

GitOps переворачивает это: желаемое состояние продакшена хранится в git-репозитории. Инструмент (обычно Argo CD) смотрит на этот репозиторий и добивается того, чтобы в кластере было именно то, что там описано. Никаких ручных деплоев — только изменения в git.

Практические следствия:

  • история деплоев = git log — понятно, что, когда и кто поменял;
  • откат = revert — обычная git-операция, воспроизводимая и понятная;
  • аудит бесплатный — всё задокументировано в коммитах.

Из этого вытекают ещё две вещи. Ручные правки в кластере (kubectl edit) оператор видит как расхождение с репозиторием — а вот вернёт ли он всё назад сам, зависит от настройки. В Argo CD автоматическая синхронизация по умолчанию выключена, а самолечение (selfHeal) — отдельный переключатель поверх неё. Без них приложение просто помечается как разошедшееся и так и висит, пока человек не решит, что с ним делать; с ними ручная правка живёт минуты. Вывод в обоих случаях один: срочные изменения тоже делают коммитом, пусть и по ускоренному пути.

И в репозиторий манифестов нельзя класть секреты открытым текстом: их шифруют (Sealed Secrets, SOPS) или хранят во внешнем хранилище, а оператор подставляет значения при применении. Откат в такой схеме — git revert.

Почему манифесты живут в отдельном репозитории

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

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

Кто обновляет версию после сборки

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

В обоих случаях правило одно: версию меняет автоматика, а человек её подтверждает — тем, что сливает pull request в каталог прода.

Продвижение со стенда на прод

Продвижение — это перенос одного значения: дайджест, который отработал на стенде, вписывается в описание прода отдельным pull request. Никакой новой сборки, никакого «соберём для прода» — ровно то, о чём принцип build once. У pull request есть свои ревьюеры (владелец выпуска, дежурный), и именно он является ручными воротами, о которых говорит статья про принципы конвейера. Хорошая практика — писать в его описании, что прошло на стенде: какие проверки, сколько времени продержалось, какой номер изменения.

Дрейф: что это и где его видно

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

Что с этим делают. Настоящие ручные правки лечат самолечением: оператор возвращает описанное состояние, а автор правки получает урок, что срочное тоже идёт коммитом. Ожидаемые расхождения исключают из сравнения явно (в Argo CD это ignoreDifferences по конкретным полям) — иначе приложение висит разошедшимся постоянно, команда привыкает к жёлтому статусу и перестаёт его читать. Это, кстати, главная причина, по которой GitOps не приживается: не техника, а потеря доверия к индикатору.

Петля от коммита до продакшена

Вот как выглядит полный цикл в команде с trunk-based + GitOps:

PR конвейер тесты, линтер, сборка слияние в main образ тег с SHA выкат на стенд проверка PR в GitOps новый тег Argo CD обновляет прод флаг когда функция готова

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

Человеческих решений в этой петле два: одобрить PR и включить флаг. Остальное — автоматика.

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

ПроблемаЧто происходитКак исправить
Ветка фичи живёт месяцКонфликтное слияние, непротестированные стыкиРезать на маленькие PR + feature flags
Develop + release + hotfix для обычного сервисаЛишние церемонии, непонятно где правдаTrunk-based: main — единственная правда
PR висит неделюБлокировки, конфликты, демотивацияРевью в течение дня — командная норма
Версия «1.0-final-new2» вручнуюНепонятно что в продакшенеSHA для сервисов, авто-SemVer для библиотек
Деплой «по договорённости в чате»Неповторяемо, неотслеживаемоGitOps: PR в репозиторий конфигурации — единственный путь
Огромный PR «вся функция сразу»Ревью по диагонали, баги в продакшенеВертикальные срезы; флаги для неготового
Дополнительно: при первом чтении можно пропустить

Глубже: защита ветки и очередь слияниярасширенное

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

Защита ветки. Настройки на main, которые включают в первый день: слияние только через pull request; обязательные проверки, без зелёного статуса которых кнопка неактивна; ветка обязана быть обновлена относительно main перед слиянием; запрет принудительной записи и удаления, чтобы историю нельзя было переписать; число одобрений (одно для обычного кода, два для платежей и инфраструктуры) с сбросом устаревших одобрений при новых коммитах; владельцы кода (файл CODEOWNERS), чьё одобрение обязательно для своих каталогов; обязательное закрытие обсуждений. Администраторы подчиняются тем же правилам, иначе правила ничего не значат.

Очередь слияния. Требование «ветка обновлена» решает проблему двух PR, но при десяти PR в час превращается в гонку: каждый раз, когда кто-то слился, остальные обновляют ветку и ждут проверки заново. Очередь слияния (merge queue у GitHub, merge trains у GitLab) делает это сама: PR ставится в очередь, система создаёт временную ветку из main плюс все PR перед ним, гоняет проверки на ней и сливает по порядку; PR, на котором проверки упали, выбрасывается из очереди, а остальные пересобираются без него. Разработчик нажимает «в очередь» и уходит, а main остаётся зелёным по построению. Цена: проверки на очереди должны быть быстрыми, потому что они на критическом пути каждого слияния, и это возвращает к бюджету времени конвейера.

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

Коротко

  • Длинные ветки создают конфликты и непротестированные стыки — цена слияния растёт не по прямой: ветка вдвое старше обходится примерно вчетверо дороже.
  • Trunk-based development: одна главная ветка, изменения попадают в неё быстро через маленькие короткоживущие ветки.
  • Незавершённый код прячется за feature flags — это разделяет деплой кода и включение функции.
  • Для сервисов — SHA как версия; для библиотек и публичных API — SemVer, выводимый автоматически из conventional commits.
  • GitOps: желаемое состояние продакшена описано в git, Argo CD следит за соответствием. Откат = revert, история деплоев = git log.
  • Squash при слиянии держит историю main читаемой.
  • Зелёный main держится настройками: слияние только через PR с обязательными проверками и актуальной веткой, запрет перезаписи истории, одобрения с владельцами кода; очередь слияния проверяет PR вместе с теми, что впереди, и снимает гонку обновлений.
  • Прод сломан: сначала откат на предыдущий дайджест, и только если откатиться нельзя — ветка от того коммита, что в проде, с одной правкой и полным конвейером; чаще выкатываешь — реже возникает эта ситуация.
  • «Маленький PR» — до 200 строк написанного руками, 400 верхняя граница, сгенерированное и перемещённое отдельно; то, что не режется по слоям, режется по последовательности: добавить, подключить, убрать старое.
  • Тег образа, git-тег и дайджест — три разных вещи; надёжен только дайджест, его пишут в описание состояния, а хеш коммита — меткой внутрь образа.

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